diff --git a/packages/analytics-browser-test/test/helpers.ts b/packages/analytics-browser-test/test/helpers.ts index d87849058..4051d6ab6 100644 --- a/packages/analytics-browser-test/test/helpers.ts +++ b/packages/analytics-browser-test/test/helpers.ts @@ -173,6 +173,7 @@ const generatePageViewEventProps = ( '[Amplitude] Page Path': url.pathname, '[Amplitude] Page Title': '', '[Amplitude] Page URL': url.href.split('?')[0], + '[Amplitude] Page View ID': uuid, ...(options?.withPageURLEnrichmentProperties ? addPageUrlEnrichmentPreviousPageProperties(url.href, previousUrl.href || '') : {}), diff --git a/packages/analytics-browser-test/test/index.test.ts b/packages/analytics-browser-test/test/index.test.ts index 7ad2b3368..5b05f7938 100644 --- a/packages/analytics-browser-test/test/index.test.ts +++ b/packages/analytics-browser-test/test/index.test.ts @@ -6,8 +6,7 @@ import { default as nock } from 'nock'; import { success } from './responses'; import 'isomorphic-fetch'; import { path, url, SUCCESS_MESSAGE, uuidPattern } from './constants'; -import { LogLevel } from '@amplitude/analytics-core'; -import { UUID } from '@amplitude/analytics-core'; +import { LogLevel, UUID } from '@amplitude/analytics-core'; describe('integration', () => { const uuid: string = expect.stringMatching(uuidPattern) as string; @@ -1953,119 +1952,125 @@ describe('integration', () => { return new Promise((resolve) => { setTimeout(() => { - expect(payload).toEqual({ - api_key: apiKey, - client_upload_time: event_upload_time, - events: [ - { - device_id: uuid, - event_id: 0, - event_properties: { - '[Amplitude] Page Domain': 'www.example.com', - '[Amplitude] Page Location': 'https://www.example.com/about', - '[Amplitude] Page Path': '/about', - '[Amplitude] Page Title': '', - '[Amplitude] Page URL': 'https://www.example.com/about', - '[Amplitude] Page Counter': 1, - '[Amplitude] Previous Page Location': '', - '[Amplitude] Previous Page Type': 'direct', + try { + expect(payload).toEqual({ + api_key: apiKey, + client_upload_time: event_upload_time, + events: [ + { + device_id: uuid, + event_id: 0, + event_properties: { + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/about', + '[Amplitude] Page Path': '/about', + '[Amplitude] Page Title': '', + '[Amplitude] Page URL': 'https://www.example.com/about', + '[Amplitude] Page View ID': uuid, + '[Amplitude] Page Counter': 1, + '[Amplitude] Previous Page Location': '', + '[Amplitude] Previous Page Type': 'direct', + }, + event_type: '[Amplitude] Page Viewed', + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', }, - event_type: '[Amplitude] Page Viewed', - insert_id: uuid, - ip: '$remote', - language: 'en-US', - library, - partner_id: undefined, - plan: undefined, - platform: 'Web', - session_id: number, - time: number, - user_agent: userAgent, - user_id: 'user1@amplitude.com', - }, - { - device_id: uuid, - event_id: 1, - event_type: 'Event in first session', - event_properties: { - '[Amplitude] Page Domain': 'www.example.com', - '[Amplitude] Page Location': 'https://www.example.com/about', - '[Amplitude] Page Path': '/about', - '[Amplitude] Page Title': '', - '[Amplitude] Page URL': 'https://www.example.com/about', - '[Amplitude] Previous Page Location': '', - '[Amplitude] Previous Page Type': 'direct', + { + device_id: uuid, + event_id: 1, + event_type: 'Event in first session', + event_properties: { + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/about', + '[Amplitude] Page Path': '/about', + '[Amplitude] Page Title': '', + '[Amplitude] Page URL': 'https://www.example.com/about', + '[Amplitude] Previous Page Location': '', + '[Amplitude] Previous Page Type': 'direct', + }, + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', }, - insert_id: uuid, - ip: '$remote', - language: 'en-US', - library, - partner_id: undefined, - plan: undefined, - platform: 'Web', - session_id: number, - time: number, - user_agent: userAgent, - user_id: 'user1@amplitude.com', - }, - { - device_id: uuid, - event_id: 2, - event_properties: { - '[Amplitude] Page Domain': 'www.example.com', - '[Amplitude] Page Location': 'https://www.example.com/contact', - '[Amplitude] Page Path': '/contact', - '[Amplitude] Page Title': '', - '[Amplitude] Page URL': 'https://www.example.com/contact', - '[Amplitude] Previous Page Location': 'https://www.example.com/about', - '[Amplitude] Previous Page Type': 'internal', - '[Amplitude] Page Counter': 2, + { + device_id: uuid, + event_id: 2, + event_properties: { + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/contact', + '[Amplitude] Page Path': '/contact', + '[Amplitude] Page Title': '', + '[Amplitude] Page URL': 'https://www.example.com/contact', + '[Amplitude] Page View ID': uuid, + '[Amplitude] Previous Page Location': 'https://www.example.com/about', + '[Amplitude] Previous Page Type': 'internal', + '[Amplitude] Page Counter': 2, + }, + event_type: '[Amplitude] Page Viewed', + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', }, - event_type: '[Amplitude] Page Viewed', - insert_id: uuid, - ip: '$remote', - language: 'en-US', - library, - partner_id: undefined, - plan: undefined, - platform: 'Web', - session_id: number, - time: number, - user_agent: userAgent, - user_id: 'user1@amplitude.com', - }, - { - device_id: uuid, - event_id: 3, - event_properties: { - '[Amplitude] Page Domain': 'www.example.com', - '[Amplitude] Page Location': 'https://www.example.com/more', - '[Amplitude] Page Path': '/more', - '[Amplitude] Page Title': '', - '[Amplitude] Page URL': 'https://www.example.com/more', - '[Amplitude] Page Counter': 1, - '[Amplitude] Previous Page Location': 'https://www.example.com/contact', - '[Amplitude] Previous Page Type': 'internal', + { + device_id: uuid, + event_id: 3, + event_properties: { + '[Amplitude] Page Domain': 'www.example.com', + '[Amplitude] Page Location': 'https://www.example.com/more', + '[Amplitude] Page Path': '/more', + '[Amplitude] Page Title': '', + '[Amplitude] Page URL': 'https://www.example.com/more', + '[Amplitude] Page Counter': 1, + '[Amplitude] Page View ID': uuid, + '[Amplitude] Previous Page Location': 'https://www.example.com/contact', + '[Amplitude] Previous Page Type': 'internal', + }, + event_type: '[Amplitude] Page Viewed', + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', }, - event_type: '[Amplitude] Page Viewed', - insert_id: uuid, - ip: '$remote', - language: 'en-US', - library, - partner_id: undefined, - plan: undefined, - platform: 'Web', - session_id: number, - time: number, - user_agent: userAgent, - user_id: 'user1@amplitude.com', + ], + options: { + min_id_length: undefined, }, - ], - options: { - min_id_length: undefined, - }, - }); - scope.done(); + }); + } finally { + scope.done(); + } resolve(); }, 4000); }); @@ -2102,6 +2107,7 @@ describe('integration', () => { '[Amplitude] Page Path': '', '[Amplitude] Page Title': '', '[Amplitude] Page URL': '', + '[Amplitude] Page View ID': uuid, '[Amplitude] Page Counter': 1, '[Amplitude] Previous Page Location': '', '[Amplitude] Previous Page Type': 'direct', diff --git a/packages/analytics-browser/CHANGELOG.md b/packages/analytics-browser/CHANGELOG.md index 62f512e2c..04d27756d 100644 --- a/packages/analytics-browser/CHANGELOG.md +++ b/packages/analytics-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.34.1-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-browser@2.34.0...@amplitude/analytics-browser@2.34.1-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/analytics-browser + + + + + # [2.34.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-browser@2.33.5...@amplitude/analytics-browser@2.34.0) (2026-01-26) diff --git a/packages/analytics-browser/README.md b/packages/analytics-browser/README.md index 9741e52cc..9712b9322 100644 --- a/packages/analytics-browser/README.md +++ b/packages/analytics-browser/README.md @@ -53,7 +53,7 @@ This SDK is also available through CDN. Copy the script loader below and paste b ```html diff --git a/packages/analytics-browser/generated/amplitude-bookmarklet-snippet.js b/packages/analytics-browser/generated/amplitude-bookmarklet-snippet.js index 2fe19016e..b84d29e2e 100644 --- a/packages/analytics-browser/generated/amplitude-bookmarklet-snippet.js +++ b/packages/analytics-browser/generated/amplitude-bookmarklet-snippet.js @@ -51,10 +51,10 @@ s.parentNode.insertBefore(autoTrackingPluginScript, s); var as = document.createElement('script'); as.type = 'text/javascript'; - as.integrity = 'sha384-w6jnsz1UVI8/tE15OISsDGEwBXn+DnL4t6sAQk2CimY5iBtLvv5Ct4i+Hm8mIPmJ'; + as.integrity = 'sha384-Em+bN6B9RH0gMZDyKd93Tvq6WSa1wEURGImQWotiXs2DmcN/D05ZSQE95ZbuHh2C'; as.crossOrigin = 'anonymous'; as.async = false; - as.src = 'https://cdn.amplitude.com/libs/analytics-browser-2.34.0-min.js.gz'; + as.src = 'https://cdn.amplitude.com/libs/analytics-browser-2.34.1-feat-zoning-alpha.0-min.js.gz'; as.onload = function () { if (!window.amplitude.runQueuedFunctions) { console.log('[Amplitude] Error: could not load SDK'); diff --git a/packages/analytics-browser/generated/amplitude-gtm-snippet.js b/packages/analytics-browser/generated/amplitude-gtm-snippet.js index 35a18a64e..642351e4d 100644 --- a/packages/analytics-browser/generated/amplitude-gtm-snippet.js +++ b/packages/analytics-browser/generated/amplitude-gtm-snippet.js @@ -55,10 +55,10 @@ amplitude.invoked = true; var as = document.createElement('script'); as.type = 'text/javascript'; - as.integrity = 'sha384-7QoPX152N+s8yXqWIZ1tsS6wy+0Y41HcRNGYGVmUPP1Utn+5cUJREu2pOYDu/cvv'; + as.integrity = 'sha384-Yv1uxYBttM72z+gWaASG94feHPVlCcA0wiN+84UNWfb6MGh7caMBhsY1YmhnBNw5'; as.crossOrigin = 'anonymous'; as.async = true; - as.src = 'https://cdn.amplitude.com/libs/analytics-browser-gtm-2.34.0-min.js.gz'; + as.src = 'https://cdn.amplitude.com/libs/analytics-browser-gtm-2.34.1-feat-zoning-alpha.0-min.js.gz'; as.onload = function () { if (!window.amplitudeGTM.runQueuedFunctions) { console.log('[Amplitude] Error: could not load SDK'); diff --git a/packages/analytics-browser/generated/amplitude-snippet.js b/packages/analytics-browser/generated/amplitude-snippet.js index 5ccd11cdf..38e5f7a46 100644 --- a/packages/analytics-browser/generated/amplitude-snippet.js +++ b/packages/analytics-browser/generated/amplitude-snippet.js @@ -55,10 +55,10 @@ amplitude.invoked = true; var as = document.createElement('script'); as.type = 'text/javascript'; - as.integrity = 'sha384-w6jnsz1UVI8/tE15OISsDGEwBXn+DnL4t6sAQk2CimY5iBtLvv5Ct4i+Hm8mIPmJ'; + as.integrity = 'sha384-Em+bN6B9RH0gMZDyKd93Tvq6WSa1wEURGImQWotiXs2DmcN/D05ZSQE95ZbuHh2C'; as.crossOrigin = 'anonymous'; as.async = true; - as.src = 'https://cdn.amplitude.com/libs/analytics-browser-2.34.0-min.js.gz'; + as.src = 'https://cdn.amplitude.com/libs/analytics-browser-2.34.1-feat-zoning-alpha.0-min.js.gz'; as.onload = function () { if (!window.amplitude.runQueuedFunctions) { console.log('[Amplitude] Error: could not load SDK'); diff --git a/packages/analytics-browser/package.json b/packages/analytics-browser/package.json index 5c0539431..e51f4419c 100644 --- a/packages/analytics-browser/package.json +++ b/packages/analytics-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/analytics-browser", - "version": "2.34.0", + "version": "2.34.1-feat-zoning-alpha.0", "description": "Official Amplitude SDK for Web", "keywords": [ "analytics", diff --git a/packages/analytics-browser/src/version.ts b/packages/analytics-browser/src/version.ts index 28cc1757b..5d434e9ed 100644 --- a/packages/analytics-browser/src/version.ts +++ b/packages/analytics-browser/src/version.ts @@ -1 +1 @@ -export const VERSION = '2.34.0'; +export const VERSION = '2.34.1-feat-zoning-alpha.0'; diff --git a/packages/analytics-browser/test/plugins/remove-event-key-enrichment.ts b/packages/analytics-browser/test/plugins/remove-event-key-enrichment.ts index 2cbcf815c..cebff2632 100644 --- a/packages/analytics-browser/test/plugins/remove-event-key-enrichment.ts +++ b/packages/analytics-browser/test/plugins/remove-event-key-enrichment.ts @@ -20,7 +20,7 @@ export const removeEventKeyEnrichment = (keysToRemove: KeyOfEvent[] = []): Enric type: 'enrichment', setup: async () => undefined, execute: async (event: Event) => { - for (var key of keysToRemove) { + for (const key of keysToRemove) { delete event[key]; } return event; diff --git a/packages/analytics-client-common/CHANGELOG.md b/packages/analytics-client-common/CHANGELOG.md index 4cdfb249c..02c0079f5 100644 --- a/packages/analytics-client-common/CHANGELOG.md +++ b/packages/analytics-client-common/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.4.25-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-client-common@2.4.24...@amplitude/analytics-client-common@2.4.25-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/analytics-client-common + + + + + ## [2.4.24](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-client-common@2.4.23...@amplitude/analytics-client-common@2.4.24) (2026-01-26) **Note:** Version bump only for package @amplitude/analytics-client-common diff --git a/packages/analytics-client-common/package.json b/packages/analytics-client-common/package.json index 9604a9b9d..300d327ea 100644 --- a/packages/analytics-client-common/package.json +++ b/packages/analytics-client-common/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/analytics-client-common", - "version": "2.4.24", + "version": "2.4.25-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/analytics-core/CHANGELOG.md b/packages/analytics-core/CHANGELOG.md index 2ba2778e7..50edee4c0 100644 --- a/packages/analytics-core/CHANGELOG.md +++ b/packages/analytics-core/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.38.0-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-core@2.37.0...@amplitude/analytics-core@2.38.0-feat-zoning-alpha.0) (2026-02-03) + + +### Bug Fixes + +* **autocapture:** add test for new option ([c7eedcb](https://github.com/amplitude/Amplitude-TypeScript/commit/c7eedcbfd3121c7e3fdf51873f0bc5801a4c6d4a)) +* **autocapture:** update exposure timeout and add as an option ([5afc08f](https://github.com/amplitude/Amplitude-TypeScript/commit/5afc08f3050f224f3d57643bf650c0ce1fc2af3f)) + + +### Features + +* **background-capture:** adding background capture injection to autocapture ([c0a8757](https://github.com/amplitude/Amplitude-TypeScript/commit/c0a8757497e9fe99223b9675bada0bad338e521a)) + + + + + # [2.37.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-core@2.36.1...@amplitude/analytics-core@2.37.0) (2026-01-26) diff --git a/packages/analytics-core/package.json b/packages/analytics-core/package.json index b45ce14b7..7728ebf9c 100644 --- a/packages/analytics-core/package.json +++ b/packages/analytics-core/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/analytics-core", - "version": "2.37.0", + "version": "2.38.0-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index 8e214fd3e..786e959bd 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -74,6 +74,7 @@ export { DEFAULT_CSS_SELECTOR_ALLOWLIST, DEFAULT_DATA_ATTRIBUTE_PREFIX, DEFAULT_ACTION_CLICK_ALLOWLIST, + DEFAULT_EXPOSURE_DURATION, LabeledEvent, Trigger, DataSource, diff --git a/packages/analytics-core/src/observers/network.ts b/packages/analytics-core/src/observers/network.ts index 1c609907e..1576d3edc 100644 --- a/packages/analytics-core/src/observers/network.ts +++ b/packages/analytics-core/src/observers/network.ts @@ -56,6 +56,14 @@ type AmplitudeXMLHttpRequestSafe = { addEventListener: (type: 'loadend', listener: () => void) => void; }; +function safeInvoke(fn: () => void) { + try { + fn(); + } catch (err) { + // swallow the error + } +} + export class NetworkObserver { private eventCallbacks: Map = new Map(); // eslint-disable-next-line no-restricted-globals @@ -119,8 +127,10 @@ export class NetworkObserver { } catch (err) { // if the callback throws an error, we should catch it // to avoid breaking the fetch promise chain - /* istanbul ignore next */ - this.logger?.debug('an unexpected error occurred while triggering event callbacks', err); + safeInvoke(() => { + /* istanbul ignore next */ + this.logger?.debug('an unexpected error occurred while triggering event callbacks', err); + }); } }); } @@ -223,7 +233,7 @@ export class NetworkObserver { timestamps = this.getTimestamps(); } catch (error) { /* istanbul ignore next */ - this.logger?.debug('an unexpected error occurred while retrieving timestamps', error); + safeInvoke(() => this.logger?.debug('an unexpected error occurred while retrieving timestamps', error)); } // 2. make the call to the original fetch and preserve the response or error @@ -252,7 +262,7 @@ export class NetworkObserver { // this catch shouldn't be reachable, but keep it here for safety // because we're overriding the fetch function and better to be safe than sorry /* istanbul ignore next */ - this.logger?.debug('an unexpected error occurred while handling fetch', err); + safeInvoke(() => this.logger?.debug('an unexpected error occurred while handling fetch', err)); } // 4. return the original response or throw the original error @@ -294,8 +304,10 @@ export class NetworkObserver { if (err instanceof Error && err.name === 'InvalidStateError') { // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseText#exceptions // if we reach here, it means we don't handle responseType correctly - context.logger?.error( - `unexpected error when retrieving responseText. responseType='${xhrUnsafe.responseType}'`, + safeInvoke(() => + context.logger?.debug( + `unexpected error when retrieving responseText. responseType='${xhrUnsafe.responseType}'`, + ), ); } // the other possible error is Json Parse error which we fail silently @@ -346,7 +358,9 @@ export class NetworkObserver { } as AmplitudeAnalyticsEvent; } catch (err) { /* istanbul ignore next */ - networkObserverContext.logger?.error('an unexpected error occurred while calling xhr open', err); + safeInvoke(() => + networkObserverContext.logger?.debug('an unexpected error occurred while calling xhr open', err), + ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return originalXhrOpen.apply(xhrSafe, args as any); @@ -368,35 +382,42 @@ export class NetworkObserver { const body = args[0] as XMLHttpRequestBodyInitSafe; const requestEvent = xhrSafe.$$AmplitudeAnalyticsEvent; - xhrSafe.addEventListener('loadend', function () { - try { - const responseHeaders = xhrSafe.getAllResponseHeaders(); - const responseBodySize = xhrSafe.getResponseHeader('content-length'); - - const responseWrapper = new ResponseWrapperXhr( - xhrSafe.status, - responseHeaders, + // if xhrSafe.$$AmplitudeAnalyticsEvent is not set, it means that + // the xhr.open method was called before we monkey-patched XHR and + // the event is missed + if (xhrSafe.$$AmplitudeAnalyticsEvent) { + xhrSafe.addEventListener('loadend', function () { + try { + const responseHeaders = xhrSafe.getAllResponseHeaders(); + const responseBodySize = xhrSafe.getResponseHeader('content-length'); + + const responseWrapper = new ResponseWrapperXhr( + xhrSafe.status, + responseHeaders, + /* istanbul ignore next */ + responseBodySize ? parseInt(responseBodySize, 10) : undefined, + getJson, + ); + const requestHeaders = xhrSafe.$$AmplitudeAnalyticsEvent.headers; + const requestWrapper = new RequestWrapperXhr(body, requestHeaders); + requestEvent.status = xhrSafe.status; + networkObserverContext.handleNetworkRequestEvent( + 'xhr', + { url: requestEvent.url, method: requestEvent.method }, + requestWrapper, + responseWrapper, + undefined, + requestEvent.startTime, + requestEvent.durationStart, + ); + } catch (err) { /* istanbul ignore next */ - responseBodySize ? parseInt(responseBodySize, 10) : undefined, - getJson, - ); - const requestHeaders = xhrSafe.$$AmplitudeAnalyticsEvent.headers; - const requestWrapper = new RequestWrapperXhr(body, requestHeaders); - requestEvent.status = xhrSafe.status; - networkObserverContext.handleNetworkRequestEvent( - 'xhr', - { url: requestEvent.url, method: requestEvent.method }, - requestWrapper, - responseWrapper, - undefined, - requestEvent.startTime, - requestEvent.durationStart, - ); - } catch (err) { - /* istanbul ignore next */ - networkObserverContext.logger?.error('an unexpected error occurred while handling xhr send', err); - } - }); + safeInvoke(() => + networkObserverContext.logger?.debug('an unexpected error occurred while handling xhr send', err), + ); + } + }); + } /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */ return originalXhrSend.apply(xhrSafe, args as any); }; @@ -412,11 +433,16 @@ export class NetworkObserver { xhrProto.setRequestHeader = function (headerName: any, headerValue: any) { const xhrSafe = this as unknown as AmplitudeXMLHttpRequestSafe; try { - /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */ - xhrSafe.$$AmplitudeAnalyticsEvent.headers[headerName as string] = headerValue as string; + const analyticsEvent = xhrSafe.$$AmplitudeAnalyticsEvent; + if (analyticsEvent) { + /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */ + analyticsEvent.headers[headerName as string] = headerValue as string; + } } catch (err) { /* istanbul ignore next */ - networkObserverContext.logger?.error('an unexpected error occurred while calling xhr setRequestHeader', err); + safeInvoke(() => + networkObserverContext.logger?.debug('an unexpected error occurred while calling xhr setRequestHeader', err), + ); } /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */ originalXhrSetRequestHeader.apply(xhrSafe, [headerName, headerValue]); diff --git a/packages/analytics-core/src/types/element-interactions.ts b/packages/analytics-core/src/types/element-interactions.ts index eb810061d..6c0397742 100644 --- a/packages/analytics-core/src/types/element-interactions.ts +++ b/packages/analytics-core/src/types/element-interactions.ts @@ -35,6 +35,8 @@ export const DEFAULT_DATA_ATTRIBUTE_PREFIX = 'data-amp-track-'; */ export const DEFAULT_ACTION_CLICK_ALLOWLIST = ['div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']; +export const DEFAULT_EXPOSURE_DURATION = 150; + export interface ElementInteractionsOptions { /** * List of CSS selectors to allow auto tracking on. @@ -82,6 +84,15 @@ export interface ElementInteractionsOptions { messenger?: Messenger; }; + /** + * Options for integrating background capture features that rely on a Messenger + * to communicate with an external parent window. + */ + backgroundCaptureOptions?: { + enabled?: boolean; + messenger?: Messenger; + }; + /** * This has been deprecated in favor of rage clicks tracking * via frustrationInteractions. @@ -109,6 +120,12 @@ export interface ElementInteractionsOptions { * RegExp pattern list to allow custom patterns for text masking */ maskTextRegex?: (RegExp | { pattern: string; description: string })[]; + + /** + * Duration in milliseconds an element must be visible before it is considered exposed. + * Default is 150ms. + */ + exposureDuration?: number; } type MatchingCondition = { diff --git a/packages/analytics-core/test/index.test.ts b/packages/analytics-core/test/index.test.ts index a22d8dd45..e822ded53 100644 --- a/packages/analytics-core/test/index.test.ts +++ b/packages/analytics-core/test/index.test.ts @@ -37,6 +37,7 @@ import { DEFAULT_ACTION_CLICK_ALLOWLIST, DEFAULT_DEAD_CLICK_ALLOWLIST, DEFAULT_RAGE_CLICK_ALLOWLIST, + DEFAULT_EXPOSURE_DURATION, DEFAULT_ERROR_CLICK_ALLOWLIST, DEFAULT_RAGE_CLICK_OUT_OF_BOUNDS_THRESHOLD, DEFAULT_CSS_SELECTOR_ALLOWLIST, @@ -118,6 +119,7 @@ describe('index', () => { expect(typeof Status).toBe('object'); expect(typeof DEFAULT_ACTION_CLICK_ALLOWLIST).toBe('object'); expect(typeof DEFAULT_CSS_SELECTOR_ALLOWLIST).toBe('object'); + expect(typeof DEFAULT_EXPOSURE_DURATION).toBe('number'); expect(typeof DEFAULT_DEAD_CLICK_ALLOWLIST).toBe('object'); expect(typeof DEFAULT_RAGE_CLICK_ALLOWLIST).toBe('object'); expect(typeof DEFAULT_DATA_ATTRIBUTE_PREFIX).toBe('string'); diff --git a/packages/analytics-core/test/network-observer.test.ts b/packages/analytics-core/test/network-observer.test.ts index 966ecabff..07114bf50 100644 --- a/packages/analytics-core/test/network-observer.test.ts +++ b/packages/analytics-core/test/network-observer.test.ts @@ -865,6 +865,7 @@ describe('observeXhr', () => { onreadystatechange: (() => void) | null = null; openCalled = false; sendCalled = false; + setRequestHeaderCalled = false; constructor(options?: MockXHROptions) { if (options) { @@ -906,11 +907,15 @@ describe('observeXhr', () => { } setRequestHeader(header: string, value: string): void { + this.setRequestHeaderCalled = true; (this as any)[header] = value; } } let networkObserver: any, originalGlobal; + let originalMockXhrOpen: typeof MockXHR.prototype.open; + let originalMockXhrSend: typeof MockXHR.prototype.send; + let originalMockXhrSetRequestHeader: typeof MockXHR.prototype.setRequestHeader; beforeAll(() => { // override globalScope to include mock XHR @@ -921,6 +926,10 @@ describe('observeXhr', () => { XMLHttpRequest: MockXHR, TextEncoder, } as any; + // Save original MockXHR methods so we can restore between tests (patch persists across tests otherwise) + originalMockXhrOpen = MockXHR.prototype.open; + originalMockXhrSend = MockXHR.prototype.send; + originalMockXhrSetRequestHeader = MockXHR.prototype.setRequestHeader; }); afterAll(() => {}); @@ -969,6 +978,65 @@ describe('observeXhr', () => { xhr.setRequestHeader('X-Custom-Header', 'customvalue'); xhr.send('hello world!'); }); + + // eslint-disable-next-line jest/no-done-callback + describe('xhr.open monkey-patching order matters', () => { + // Use a fresh observer so the first test's subscribe() actually patches MockXHR (shared observer already has isObserving=true) + let networkObserver2: NetworkObserver; + beforeAll(() => { + MockXHR.prototype.open = originalMockXhrOpen; + MockXHR.prototype.send = originalMockXhrSend; + MockXHR.prototype.setRequestHeader = originalMockXhrSetRequestHeader; + networkObserver2 = new NetworkObserver(); + (networkObserver2 as unknown as any).globalScope = (networkObserver as unknown as any).globalScope; + }); + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it('should not track event when xhr.open is called before patch ($$AmplitudeAnalyticsEvent not set)', () => { + const XMLHttpRequest = (networkObserver2 as unknown as any).globalScope.XMLHttpRequest; + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://api.example.com/data'); + let callbackInvoked = false; + const callback = () => { + callbackInvoked = true; + }; + const callbackToUnsubscribe = new NetworkEventCallback(callback); + networkObserver2.subscribe(callbackToUnsubscribe); + networkObserver2.subscribe(new NetworkEventCallback(callback)); + expect(xhr.sendCalled).toBe(false); + expect(xhr.setRequestHeaderCalled).toBe(false); + xhr.setRequestHeader('X-Custom-Header', 'customvalue'); + xhr.send('body'); + jest.runAllTimers(); + expect(callbackInvoked).toBe(false); + expect(xhr.sendCalled).toBe(true); + expect(xhr.setRequestHeaderCalled).toBe(true); + expect(xhr.$$AmplitudeAnalyticsEvent).toBeUndefined(); + networkObserver2.unsubscribe(callbackToUnsubscribe); + }); + + it('should invoke callback when xhr.open is called after patch ($$AmplitudeAnalyticsEvent set)', () => { + const XMLHttpRequest = (networkObserver2 as unknown as any).globalScope.XMLHttpRequest; + const xhr = new XMLHttpRequest(); + let callbackInvoked = false; + const callback = () => { + callbackInvoked = true; + }; + networkObserver2.subscribe(new NetworkEventCallback(callback)); + xhr.open('GET', 'https://api.example.com/data'); + expect(xhr.sendCalled).toBe(false); + expect(xhr.setRequestHeaderCalled).toBe(false); + xhr.setRequestHeader('X-Custom-Header', 'customvalue'); + xhr.send('body'); + jest.advanceTimersByTime(100); + expect(xhr.$$AmplitudeAnalyticsEvent).toBeDefined(); + expect(callbackInvoked).toBe(true); + }); + }); }); describe('createXhrJsonParser()', () => { diff --git a/packages/analytics-node/CHANGELOG.md b/packages/analytics-node/CHANGELOG.md index 5893efc5c..adb3ee7c4 100644 --- a/packages/analytics-node/CHANGELOG.md +++ b/packages/analytics-node/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.5.35-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-node@1.5.34...@amplitude/analytics-node@1.5.35-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/analytics-node + + + + + ## [1.5.34](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-node@1.5.33...@amplitude/analytics-node@1.5.34) (2026-01-26) **Note:** Version bump only for package @amplitude/analytics-node diff --git a/packages/analytics-node/package.json b/packages/analytics-node/package.json index c7a028b9c..8b005fb20 100644 --- a/packages/analytics-node/package.json +++ b/packages/analytics-node/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/analytics-node", - "version": "1.5.34", + "version": "1.5.35-feat-zoning-alpha.0", "description": "Official Amplitude SDK for NodeJS", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/analytics-node/src/version.ts b/packages/analytics-node/src/version.ts index 03717b1ea..0b7c4e19d 100644 --- a/packages/analytics-node/src/version.ts +++ b/packages/analytics-node/src/version.ts @@ -1 +1 @@ -export const VERSION = '1.5.34'; +export const VERSION = '1.5.35-feat-zoning-alpha.0'; diff --git a/packages/analytics-react-native/CHANGELOG.md b/packages/analytics-react-native/CHANGELOG.md index c2367d2fa..c8f1ec635 100644 --- a/packages/analytics-react-native/CHANGELOG.md +++ b/packages/analytics-react-native/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.5.38-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-react-native@1.5.37...@amplitude/analytics-react-native@1.5.38-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/analytics-react-native + + + + + ## [1.5.37](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-react-native@1.5.36...@amplitude/analytics-react-native@1.5.37) (2026-01-26) **Note:** Version bump only for package @amplitude/analytics-react-native diff --git a/packages/analytics-react-native/package.json b/packages/analytics-react-native/package.json index 421e348dc..a48f97e5a 100644 --- a/packages/analytics-react-native/package.json +++ b/packages/analytics-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/analytics-react-native", - "version": "1.5.37", + "version": "1.5.38-feat-zoning-alpha.0", "description": "Official React Native SDK", "keywords": [ "analytics", diff --git a/packages/analytics-react-native/src/version.ts b/packages/analytics-react-native/src/version.ts index 42cc2b8e2..4f58eba9b 100644 --- a/packages/analytics-react-native/src/version.ts +++ b/packages/analytics-react-native/src/version.ts @@ -1 +1 @@ -export const VERSION = '1.5.37'; +export const VERSION = '1.5.38-feat-zoning-alpha.0'; diff --git a/packages/analytics-types/CHANGELOG.md b/packages/analytics-types/CHANGELOG.md index bf4d542fe..51a38d091 100644 --- a/packages/analytics-types/CHANGELOG.md +++ b/packages/analytics-types/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.12.0-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-types@2.11.1...@amplitude/analytics-types@2.12.0-feat-zoning-alpha.0) (2026-02-03) + + +### Features + +* **background-capture:** adding background capture injection to autocapture ([c0a8757](https://github.com/amplitude/Amplitude-TypeScript/commit/c0a8757497e9fe99223b9675bada0bad338e521a)) + + + + + ## [2.11.1](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/analytics-types@2.11.0...@amplitude/analytics-types@2.11.1) (2026-01-14) **Note:** Version bump only for package @amplitude/analytics-types diff --git a/packages/analytics-types/package.json b/packages/analytics-types/package.json index 0033aaea4..19f1481bb 100644 --- a/packages/analytics-types/package.json +++ b/packages/analytics-types/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/analytics-types", - "version": "2.11.1", + "version": "2.12.0-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/analytics-types/src/element-interactions.ts b/packages/analytics-types/src/element-interactions.ts index 0c682da94..96d908a62 100644 --- a/packages/analytics-types/src/element-interactions.ts +++ b/packages/analytics-types/src/element-interactions.ts @@ -74,6 +74,15 @@ export interface ElementInteractionsOptions { messenger?: Messenger; }; + /** + * Options for integrating background capture features that rely on a Messenger + * to communicate with an external parent window. + */ + backgroundCaptureOptions?: { + enabled?: boolean; + messenger?: Messenger; + }; + /** * Debounce time in milliseconds for tracking events. * This is used to detect rage clicks. diff --git a/packages/gtm-snippet/CHANGELOG.md b/packages/gtm-snippet/CHANGELOG.md index 5998a8bf5..058881c06 100644 --- a/packages/gtm-snippet/CHANGELOG.md +++ b/packages/gtm-snippet/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.34.1-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/gtm-snippet@2.33.6...@amplitude/gtm-snippet@2.34.1-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/gtm-snippet + + + + + # [2.34.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/gtm-snippet@2.33.5...@amplitude/gtm-snippet@2.34.0) (2026-01-26) **Note:** Version bump only for package @amplitude/gtm-snippet diff --git a/packages/gtm-snippet/package.json b/packages/gtm-snippet/package.json index 402be1552..39a5ba301 100644 --- a/packages/gtm-snippet/package.json +++ b/packages/gtm-snippet/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/gtm-snippet", - "version": "2.34.0", + "version": "2.34.1-feat-zoning-alpha.0", "description": "Amplitude JS SDK Wrapper for use with Google Tag Manager", "publishConfig": { "access": "public", diff --git a/packages/plugin-autocapture-browser/CHANGELOG.md b/packages/plugin-autocapture-browser/CHANGELOG.md index b6ff46209..f307b1b66 100644 --- a/packages/plugin-autocapture-browser/CHANGELOG.md +++ b/packages/plugin-autocapture-browser/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [1.20.0-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-autocapture-browser@1.19.0...@amplitude/plugin-autocapture-browser@1.20.0-feat-zoning-alpha.0) (2026-02-03) + + +### Bug Fixes + +* **autocapture:** add viewport size to scroll to get max X/Y ([b985fda](https://github.com/amplitude/Amplitude-TypeScript/commit/b985fda0b927c77016eb3dd9a5d00092ae2085c4)) +* **autocapture:** update exposure timeout and add as an option ([5afc08f](https://github.com/amplitude/Amplitude-TypeScript/commit/5afc08f3050f224f3d57643bf650c0ce1fc2af3f)) +* double firing behavior ([5c2fc84](https://github.com/amplitude/Amplitude-TypeScript/commit/5c2fc8457fa51977b660810340f204dc963f3702)) +* test coverage ([9774259](https://github.com/amplitude/Amplitude-TypeScript/commit/97742598dbc031da730dc7c5e672aa8c27ca3ec7)) +* test coverage ([8b7a1de](https://github.com/amplitude/Amplitude-TypeScript/commit/8b7a1def6874bb22497bb9fa670c40817c86a9f2)) +* update tests to add new element path property ([6efd0ce](https://github.com/amplitude/Amplitude-TypeScript/commit/6efd0ce43bed6bdbd98ff50487712b6bf235bb28)) + + +### Features + +* **autocapture:** add method to get unique element path for element ([1e762dc](https://github.com/amplitude/Amplitude-TypeScript/commit/1e762dca5d5c4c530a889dac51486d0705d415f2)) +* **background-capture:** adding background capture injection to autocapture ([c0a8757](https://github.com/amplitude/Amplitude-TypeScript/commit/c0a8757497e9fe99223b9675bada0bad338e521a)) +* **page-view-tracking-browser:** Track page view id in session storage ([#1379](https://github.com/amplitude/Amplitude-TypeScript/issues/1379)) ([51fd3d2](https://github.com/amplitude/Amplitude-TypeScript/commit/51fd3d2ce3d22c312d12079dd8c036a5520df0f1)) + + + + + # [1.19.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-autocapture-browser@1.18.7...@amplitude/plugin-autocapture-browser@1.19.0) (2026-01-26) diff --git a/packages/plugin-autocapture-browser/package.json b/packages/plugin-autocapture-browser/package.json index 41f6b2752..9702ac4c0 100644 --- a/packages/plugin-autocapture-browser/package.json +++ b/packages/plugin-autocapture-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-autocapture-browser", - "version": "1.19.0", + "version": "1.20.0-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-autocapture-browser/rollup.config.js b/packages/plugin-autocapture-browser/rollup.config.js index 4c31201a1..dcf0f7ea0 100644 --- a/packages/plugin-autocapture-browser/rollup.config.js +++ b/packages/plugin-autocapture-browser/rollup.config.js @@ -7,4 +7,4 @@ iife.output.name = 'amplitudeAutocapturePlugin'; if (process.env.NODE_ENV === 'development') { iife.output.sourcemap = 'inline'; } -export default [umd, iife]; +export default [umd, iife]; \ No newline at end of file diff --git a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts index b472995dc..099b7197b 100644 --- a/packages/plugin-autocapture-browser/src/autocapture-plugin.ts +++ b/packages/plugin-autocapture-browser/src/autocapture-plugin.ts @@ -4,6 +4,7 @@ import { type BrowserConfig, type EnrichmentPlugin, type ElementInteractionsOptions, + DEFAULT_EXPOSURE_DURATION, DEFAULT_CSS_SELECTOR_ALLOWLIST, DEFAULT_ACTION_CLICK_ALLOWLIST, DEFAULT_DATA_ATTRIBUTE_PREFIX, @@ -23,7 +24,14 @@ import { WindowMessenger } from './libs/messenger'; import { trackClicks } from './autocapture/track-click'; import { trackChange } from './autocapture/track-change'; import { trackActionClick } from './autocapture/track-action-click'; -import { createClickObservable, createMutationObservable } from './observables'; +import { trackScroll } from './autocapture/track-scroll'; + +import { + createClickObservable, + createScrollObservable, + createExposureObservable, + createMutationObservable, +} from './observables'; import { createLabeledEventToTriggerMap, @@ -32,6 +40,8 @@ import { } from './pageActions/triggers'; import { DataExtractor } from './data-extractor'; import { Observable, Unsubscribable } from '@amplitude/analytics-core'; +import { trackExposure } from './autocapture/track-exposure'; +import { fireViewportContentUpdated, onExposure, ExposureTracker } from './autocapture/track-viewport-content-updated'; type NavigationType = { addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; @@ -54,9 +64,10 @@ export type AutoCaptureOptionsWithDefaults = Required< export enum ObservablesEnum { ClickObservable = 'clickObservable', ChangeObservable = 'changeObservable', - // ErrorObservable = 'errorObservable', NavigateObservable = 'navigateObservable', MutationObservable = 'mutationObservable', + ScrollObservable = 'scrollObservable', + ExposureObservable = 'exposureObservable', BrowserErrorObservable = 'browserErrorObservable', SelectionObservable = 'selectionObservable', } @@ -67,6 +78,8 @@ export interface AllWindowObservables { [ObservablesEnum.ClickObservable]: Observable>; [ObservablesEnum.MutationObservable]: Observable>; [ObservablesEnum.NavigateObservable]?: Observable>; + [ObservablesEnum.ScrollObservable]: Observable; // TODO: add type for scroll event + [ObservablesEnum.ExposureObservable]: Observable; [ObservablesEnum.SelectionObservable]?: Observable; } @@ -84,11 +97,17 @@ export const autocapturePlugin = ( enabled: true, messenger: new WindowMessenger(), }, + /* istanbul ignore next */ + backgroundCaptureOptions = { + enabled: true, + messenger: new WindowMessenger(), + }, } = options; options.cssSelectorAllowlist = options.cssSelectorAllowlist ?? DEFAULT_CSS_SELECTOR_ALLOWLIST; options.actionClickAllowlist = options.actionClickAllowlist ?? DEFAULT_ACTION_CLICK_ALLOWLIST; options.debounceTime = options.debounceTime ?? 0; + options.exposureDuration = options.exposureDuration ?? DEFAULT_EXPOSURE_DURATION; options.pageUrlExcludelist = options.pageUrlExcludelist?.reduce( (acc: (string | RegExp | { pattern: string })[], excludePattern) => { @@ -116,10 +135,16 @@ export const autocapturePlugin = ( const subscriptions: Unsubscribable[] = []; - // Create data extractor based on options const dataExtractor = new DataExtractor(options, context); - // Create observables on events on the window + // Page-level state shared across trackers, emitted in a single Page View End event on beforeunload + // elementExposedForPage holds the total set of elements seen during the entire page view lifetime + const elementExposedForPage = new Set(); + // currentElementExposed only holds the set of elements that will be flushed during the next [Amplitude] Viewport Content Updated event + const currentElementExposed = new Set(); + + let beforeUnloadCleanup: () => void; + const createObservables = (): AllWindowObservables => { const clickObservable = multicast( createClickObservable().map( @@ -151,11 +176,6 @@ export const autocapturePlugin = ( }), ); - // Create Observable from unhandled errors - // const errorObservable = fromEvent(window, 'error').pipe( - // map((error) => addAdditionalEventProperties(error, 'error')), - // ); - // Create observable for URL changes let navigateObservable: Observable> | undefined; @@ -191,12 +211,21 @@ export const autocapturePlugin = ( ), ); + const scrollObservable = createScrollObservable(); + + const exposureObservable = createExposureObservable( + mutationObservable, + (options as AutoCaptureOptionsWithDefaults).cssSelectorAllowlist, + ); + return { [ObservablesEnum.ChangeObservable]: changeObservable, // [ObservablesEnum.ErrorObservable]: errorObservable, [ObservablesEnum.ClickObservable]: clickObservable, [ObservablesEnum.MutationObservable]: mutationObservable, [ObservablesEnum.NavigateObservable]: navigateObservable, + [ObservablesEnum.ScrollObservable]: scrollObservable, + [ObservablesEnum.ExposureObservable]: exposureObservable, }; }; @@ -237,6 +266,9 @@ export const autocapturePlugin = ( return; } + let pageViewEndFired = false; + const lastScroll: { maxX: undefined | number; maxY: undefined | number } = { maxX: undefined, maxY: undefined }; + // Fetch remote config for pageActions in a non-blocking manner if (config.fetchRemoteConfig) { if (!config.remoteConfigClient) { @@ -293,9 +325,112 @@ export const autocapturePlugin = ( subscriptions.push(actionClickSubscription); } + const scrollTracker = trackScroll({ + allObservables, + amplitude, + }); + subscriptions.push(scrollTracker); + + const trackers: { exposure?: ExposureTracker & Unsubscribable } = {}; + + const globalScope = getGlobalScope(); + + const handleViewportContentUpdated = (isPageEnd: boolean) => { + if (isPageEnd && pageViewEndFired) { + return; + } + setTimeout(() => { + pageViewEndFired = false; + }, 100); + + pageViewEndFired = true; + fireViewportContentUpdated({ + amplitude, + scrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: trackers.exposure, + isPageEnd, + lastScroll, + }); + }; + + const handleExposure = (elementPath: string) => { + onExposure(elementPath, elementExposedForPage, currentElementExposed, handleViewportContentUpdated); + }; + + trackers.exposure = trackExposure({ + allObservables, + onExposure: handleExposure, + dataExtractor, + exposureDuration: options.exposureDuration, + }); + if (trackers.exposure) { + subscriptions.push(trackers.exposure); + } + + const beforeUnloadHandler = () => { + console.log('amp: beforeUnload'); + handleViewportContentUpdated(true); + }; /* istanbul ignore next */ - config?.loggerProvider?.log(`${name} has been successfully added.`); + globalScope?.addEventListener('beforeunload', beforeUnloadHandler); + beforeUnloadCleanup = () => { + /* istanbul ignore next */ + globalScope?.removeEventListener('beforeunload', beforeUnloadHandler); + }; + // Ensure cleanup on teardown as well + subscriptions.push({ unsubscribe: () => beforeUnloadCleanup() }); + + // Also track on navigation (SPA) + const navigateObservable = allObservables[ObservablesEnum.NavigateObservable]; + if (navigateObservable) { + subscriptions.push( + navigateObservable.subscribe(() => { + console.log('amp: navigate'); + handleViewportContentUpdated(true); + }), + ); + } else if (globalScope) { + const popstateHandler = () => { + console.log('amp: popstate'); + handleViewportContentUpdated(true); + }; + /* istanbul ignore next */ + // Fallback for SPA tracking when Navigation API is not available + globalScope.addEventListener('popstate', popstateHandler); + /* istanbul ignore next */ + // There is no global browser listener for changes to history, so we have + // to modify pushState directly. + // https://stackoverflow.com/a/64927639 + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalPushState = globalScope.history.pushState; + if (globalScope.history && originalPushState) { + // eslint-disable-next-line @typescript-eslint/unbound-method + globalScope.history.pushState = new Proxy(originalPushState, { + apply: (target, thisArg, [state, unused, url]) => { + console.log('amp: pushState'); + target.apply(thisArg, [state, unused, url]); + handleViewportContentUpdated(true); + }, + }); + } + + subscriptions.push({ + unsubscribe: () => { + /* istanbul ignore next */ + globalScope.removeEventListener('popstate', popstateHandler); + /* istanbul ignore next */ + if (globalScope.history && originalPushState) { + globalScope.history.pushState = originalPushState; + } + }, + }); + } + + /* istanbul ignore next */ + config?.loggerProvider?.log(`${name} has been successfully added.`); // Setup visual tagging selector if (window.opener && visualTaggingOptions.enabled) { const allowlist = (options as AutoCaptureOptionsWithDefaults).cssSelectorAllowlist; @@ -311,6 +446,16 @@ export const autocapturePlugin = ( actionClickAllowlist: actionClickAllowlist, }); } + + // Setup background capture messenger if it is not already setup for visual tagging selector + /* istanbul ignore next */ + if (window.opener && backgroundCaptureOptions.enabled && !visualTaggingOptions.messenger) { + /* istanbul ignore next */ + backgroundCaptureOptions.messenger?.setup({ + dataExtractor: dataExtractor, + logger: config?.loggerProvider, + }); + } }; const execute: BrowserEnrichmentPlugin['execute'] = async (event) => { diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-click.ts b/packages/plugin-autocapture-browser/src/autocapture/track-click.ts index a0b24dcfb..29bbc8050 100644 --- a/packages/plugin-autocapture-browser/src/autocapture/track-click.ts +++ b/packages/plugin-autocapture-browser/src/autocapture/track-click.ts @@ -1,5 +1,5 @@ import { AllWindowObservables } from '../autocapture-plugin'; -import { ElementBasedEvent, ElementBasedTimestampedEvent, type evaluateTriggersFn } from '../helpers'; +import { ElementBasedEvent, type ElementBasedTimestampedEvent, type evaluateTriggersFn } from '../helpers'; import { Observable, BrowserClient } from '@amplitude/analytics-core'; import { filterOutNonTrackableEvents, shouldTrackEvent } from '../helpers'; import { AMPLITUDE_ELEMENT_CLICKED_EVENT } from '../constants'; @@ -19,11 +19,11 @@ export function trackClicks({ const clickObservableFiltered = clickObservable .filter(filterOutNonTrackableEvents) - .filter((click) => { + .filter((click: ElementBasedTimestampedEvent) => { // Only track clicks on elements that should be tracked, return shouldTrackEvent('click', click.closestTrackedAncestor); }) - .map((click) => evaluateTriggers(click)); + .map((click: ElementBasedTimestampedEvent) => evaluateTriggers(click)); const clicks: Observable ? U : never> = clickObservableFiltered; diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-exposure.ts b/packages/plugin-autocapture-browser/src/autocapture/track-exposure.ts new file mode 100644 index 000000000..ba9c4c5d8 --- /dev/null +++ b/packages/plugin-autocapture-browser/src/autocapture/track-exposure.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-restricted-globals */ +import { AllWindowObservables } from '../autocapture-plugin'; +import { DataExtractor } from '../data-extractor'; + +// Default duration an element must be visible to count as "exposed" +export const DEFAULT_EXPOSURE_DURATION = 150; + +export function trackExposure({ + allObservables, + onExposure, + dataExtractor, + exposureDuration = DEFAULT_EXPOSURE_DURATION, +}: { + allObservables: AllWindowObservables; + onExposure: (elementPath: string) => void; + dataExtractor: DataExtractor; + exposureDuration?: number; +}) { + // Track which elements have been marked as exposed (per-element state) + const exposureMap = new Map(); + + // Track active timers for elements that are currently visible but not yet exposed + const exposureTimerMap = new Map | null | undefined>(); + + const { exposureObservable } = allObservables; + + const exposureSubscription = exposureObservable.subscribe((event) => { + const entry = event as unknown as IntersectionObserverEntry; + const element = entry.target; + + if (entry.isIntersecting) { + // Element became visible - start exposure timer if not already exposed + if (!exposureMap.get(element)) { + const timer = setTimeout(() => { + // Element has been visible for exposureDuration - mark as exposed + exposureMap.set(element, true); + + // Record the CSS selector path in the shared exposure state + const elementPath = dataExtractor.getElementPath(element); + onExposure(elementPath); + + // Clear the timer reference + exposureTimerMap.set(element, null); + }, exposureDuration); + + exposureTimerMap.set(element, timer); + } + } else if (!entry.isIntersecting && entry.intersectionRatio < 1.0) { + // Element left viewport - cancel exposure timer if one exists + const timer = exposureTimerMap.get(element); + if (timer) { + clearTimeout(timer); + exposureTimerMap.set(element, null); + } + } + }); + + return { + unsubscribe: () => { + exposureSubscription.unsubscribe(); + }, + reset: () => { + exposureTimerMap.forEach((timer) => { + if (timer) { + clearTimeout(timer); + } + }); + exposureTimerMap.clear(); + exposureMap.clear(); + }, + }; +} diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-scroll.ts b/packages/plugin-autocapture-browser/src/autocapture/track-scroll.ts new file mode 100644 index 000000000..eac6b66dc --- /dev/null +++ b/packages/plugin-autocapture-browser/src/autocapture/track-scroll.ts @@ -0,0 +1,44 @@ +import { AllWindowObservables } from '../autocapture-plugin'; +import { BrowserClient, getGlobalScope } from '@amplitude/analytics-core'; + +export interface ScrollState { + maxX: number; + maxY: number; +} + +export function trackScroll({ + amplitude, + allObservables, +}: { + amplitude: BrowserClient; + allObservables: AllWindowObservables; +}) { + // amplitude is reserved for future periodic scroll event tracking + void amplitude; + + const { scrollObservable } = allObservables; + const state: ScrollState = { maxX: 0, maxY: 0 }; + + const scrollSubscription = scrollObservable.subscribe(() => { + const globalScope = getGlobalScope(); + /* istanbul ignore next */ + const currentX = Math.floor(globalScope?.scrollX ?? globalScope?.pageXOffset ?? 0); + /* istanbul ignore next */ + const currentY = Math.floor(globalScope?.scrollY ?? globalScope?.pageYOffset ?? 0); + + // Update page-level max positions for Page View End event (never resets during page lifetime) + state.maxX = Math.max(state.maxX, currentX); + state.maxY = Math.max(state.maxY, currentY); + }); + + return { + unsubscribe: () => { + scrollSubscription.unsubscribe(); + }, + getState: () => state, + reset: () => { + state.maxX = 0; + state.maxY = 0; + }, + }; +} diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts b/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts new file mode 100644 index 000000000..1ef028829 --- /dev/null +++ b/packages/plugin-autocapture-browser/src/autocapture/track-viewport-content-updated.ts @@ -0,0 +1,98 @@ +import { BrowserClient, getGlobalScope } from '@amplitude/analytics-core'; +import * as constants from '../constants'; +import { getCurrentPageViewId } from '../helpers'; + +export interface ScrollTracker { + getState: () => { maxX: number; maxY: number }; + reset: () => void; +} + +export interface ExposureTracker { + reset: () => void; +} + +export function fireViewportContentUpdated({ + amplitude, + scrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker, + isPageEnd, + lastScroll, +}: { + amplitude: BrowserClient; + scrollTracker: ScrollTracker; + currentElementExposed: Set; + elementExposedForPage: Set; + exposureTracker: ExposureTracker | undefined; + isPageEnd: boolean; + lastScroll: { maxX: undefined | number; maxY: undefined | number }; +}): void { + const pageScrollMaxState = scrollTracker.getState(); + const globalScope = getGlobalScope(); + + /* istanbul ignore next */ + const viewportWidth = globalScope?.innerWidth ?? 0; + /* istanbul ignore next */ + const viewportHeight = globalScope?.innerHeight ?? 0; + + const eventProperties: Record = { + [constants.AMPLITUDE_EVENT_PROP_PAGE_URL]: + /* istanbul ignore next */ + globalScope?.location?.href, + [constants.AMPLITUDE_EVENT_PROP_MAX_PAGE_X]: pageScrollMaxState.maxX + viewportWidth, + [constants.AMPLITUDE_EVENT_PROP_MAX_PAGE_Y]: pageScrollMaxState.maxY + viewportHeight, + [constants.AMPLITUDE_EVENT_PROP_VIEWPORT_HEIGHT]: viewportHeight, + [constants.AMPLITUDE_EVENT_PROP_VIEWPORT_WIDTH]: viewportWidth, + '[Amplitude] Element Exposed': Array.from(currentElementExposed), + }; + + const pageViewId = getCurrentPageViewId(); + if (pageViewId) { + eventProperties[constants.AMPLITUDE_EVENT_PROP_PAGE_VIEW_ID] = pageViewId; + } + + // If elements exposed is empty and max scroll is same as last event, don't track + if ( + currentElementExposed.size === 0 && + pageScrollMaxState.maxX === lastScroll.maxX && + pageScrollMaxState.maxY === lastScroll.maxY + ) { + return; + } + + /* istanbul ignore next */ + amplitude?.track('[Amplitude] Viewport Content Updated', eventProperties); + lastScroll = { maxX: pageScrollMaxState.maxX, maxY: pageScrollMaxState.maxY }; + + // Clear current batch + currentElementExposed.clear(); + + if (isPageEnd) { + // Reset state for next page view + scrollTracker.reset(); + elementExposedForPage.clear(); + exposureTracker?.reset(); + } +} + +export function onExposure( + elementPath: string, + elementExposedForPage: Set, + currentElementExposed: Set, + fireViewportContentUpdatedCallback: (isPageEnd: boolean) => void, +) { + if (elementExposedForPage.has(elementPath)) { + return; + } + elementExposedForPage.add(elementPath); + currentElementExposed.add(elementPath); + + // Check if current set size exceeds 18k chars + const exposedArray = Array.from(currentElementExposed); + const exposedString = JSON.stringify(exposedArray); + + if (exposedString.length >= constants.MAX_ELEMENT_EXPOSED_STR_LENGTH) { + fireViewportContentUpdatedCallback(false); + } +} diff --git a/packages/plugin-autocapture-browser/src/constants.ts b/packages/plugin-autocapture-browser/src/constants.ts index 1c97ad9ca..9e93a5761 100644 --- a/packages/plugin-autocapture-browser/src/constants.ts +++ b/packages/plugin-autocapture-browser/src/constants.ts @@ -6,6 +6,7 @@ export const AMPLITUDE_ELEMENT_DEAD_CLICKED_EVENT = '[Amplitude] Dead Click'; export const AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT = '[Amplitude] Rage Click'; export const AMPLITUDE_ELEMENT_ERROR_CLICKED_EVENT = '[Amplitude] Error Click'; export const AMPLITUDE_ELEMENT_CHANGED_EVENT = '[Amplitude] Element Changed'; +export const AMPLITUDE_PAGE_SCROLLED_EVENT = '[Amplitude] Page Scrolled'; export const AMPLITUDE_EVENT_PROP_ELEMENT_ID = '[Amplitude] Element ID'; export const AMPLITUDE_EVENT_PROP_ELEMENT_CLASS = '[Amplitude] Element Class'; @@ -17,12 +18,17 @@ export const AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_LEFT = '[Amplitude] Element P export const AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_TOP = '[Amplitude] Element Position Top'; export const AMPLITUDE_EVENT_PROP_ELEMENT_ARIA_LABEL = '[Amplitude] Element Aria Label'; export const AMPLITUDE_EVENT_PROP_ELEMENT_ATTRIBUTES = '[Amplitude] Element Attributes'; +export const AMPLITUDE_EVENT_PROP_ELEMENT_PATH = '[Amplitude] Element Path'; export const AMPLITUDE_EVENT_PROP_ELEMENT_PARENT_LABEL = '[Amplitude] Element Parent Label'; export const AMPLITUDE_EVENT_PROP_PAGE_URL = '[Amplitude] Page URL'; export const AMPLITUDE_EVENT_PROP_PAGE_TITLE = '[Amplitude] Page Title'; export const AMPLITUDE_EVENT_PROP_VIEWPORT_HEIGHT = '[Amplitude] Viewport Height'; export const AMPLITUDE_EVENT_PROP_VIEWPORT_WIDTH = '[Amplitude] Viewport Width'; +export const AMPLITUDE_EVENT_PROP_MAX_PAGE_X = '[Amplitude] Max Page X'; +export const AMPLITUDE_EVENT_PROP_MAX_PAGE_Y = '[Amplitude] Max Page Y'; + +export const AMPLITUDE_EVENT_PROP_PAGE_VIEW_ID = '[Amplitude] Page View ID'; // Visual Tagging related constants export const AMPLITUDE_ORIGIN = 'https://app.amplitude.com'; @@ -36,6 +42,8 @@ export const AMPLITUDE_ORIGINS_MAP = { export const AMPLITUDE_VISUAL_TAGGING_SELECTOR_SCRIPT_URL = 'https://cdn.amplitude.com/libs/visual-tagging-selector-1.0.0-alpha.js.gz'; +export const AMPLITUDE_BACKGROUND_CAPTURE_SCRIPT_URL = + 'https://cdn.amplitude.com/libs/background-capture-1.0.0-alpha.1.js.gz'; // This is the class name used by the visual tagging selector to highlight the selected element. // Should not use this class in the selector. export const AMPLITUDE_VISUAL_TAGGING_HIGHLIGHT_CLASS = 'amp-visual-tagging-selector-highlight'; @@ -44,4 +52,10 @@ export const AMPLITUDE_VISUAL_TAGGING_HIGHLIGHT_CLASS = 'amp-visual-tagging-sele export const DATA_AMP_MASK_ATTRIBUTES = 'data-amp-mask-attributes'; export const MAX_MASK_TEXT_PATTERNS = 25; + export const MAX_ATTRIBUTE_LENGTH = 128; + +// The key for the page view object in sessionStorage +export const PAGE_VIEW_SESSION_STORAGE_KEY = 'AMP_PAGE_VIEW'; + +export const MAX_ELEMENT_EXPOSED_STR_LENGTH = 18_000; diff --git a/packages/plugin-autocapture-browser/src/data-extractor.ts b/packages/plugin-autocapture-browser/src/data-extractor.ts index 96011c8f5..7f240da08 100644 --- a/packages/plugin-autocapture-browser/src/data-extractor.ts +++ b/packages/plugin-autocapture-browser/src/data-extractor.ts @@ -18,11 +18,13 @@ import { getClosestElement, isElementBasedEvent, parseAttributesToMask, + getCurrentPageViewId, } from './helpers'; import type { BaseTimestampedEvent, ElementBasedTimestampedEvent, TimestampedEvent, JSONValue } from './helpers'; import { getAncestors, getElementProperties } from './hierarchy'; import { getDataSource } from './pageActions/actions'; import { Hierarchy } from './typings/autocapture'; +import { cssPath } from './libs/element-path'; export class DataExtractor { private readonly additionalMaskTextPatterns: RegExp[]; @@ -126,6 +128,20 @@ export class DataExtractor { return this.getNearestLabel(parent); }; + getElementPath = (element: Element | null): string => { + if (!element) { + return ''; + } + const startTime = performance.now(); + + const elementPath = cssPath(element); + + const endTime = performance.now(); + this.diagnosticsClient?.recordHistogram('autocapturePlugin.getElementPath', endTime - startTime); + + return elementPath; + }; + // Returns the Amplitude event properties for the given element. getEventProperties = (actionType: ActionType, element: Element, dataAttributePrefix: string) => { /* istanbul ignore next */ @@ -147,6 +163,7 @@ export class DataExtractor { [constants.AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_LEFT]: rect.left == null ? null : Math.round(rect.left), [constants.AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_TOP]: rect.top == null ? null : Math.round(rect.top), [constants.AMPLITUDE_EVENT_PROP_ELEMENT_ATTRIBUTES]: attributes, + [constants.AMPLITUDE_EVENT_PROP_ELEMENT_PATH]: this.getElementPath(element), [constants.AMPLITUDE_EVENT_PROP_ELEMENT_PARENT_LABEL]: nearestLabel, [constants.AMPLITUDE_EVENT_PROP_PAGE_URL]: getDecodeURI(window.location.href.split('?')[0]), [constants.AMPLITUDE_EVENT_PROP_PAGE_TITLE]: ( @@ -156,6 +173,13 @@ export class DataExtractor { [constants.AMPLITUDE_EVENT_PROP_VIEWPORT_WIDTH]: window.innerWidth, }; + const pageViewId = getCurrentPageViewId(); + /* istanbul ignore next */ + if (pageViewId) { + /* istanbul ignore next */ + properties[constants.AMPLITUDE_EVENT_PROP_PAGE_VIEW_ID] = pageViewId; + } + // id is never masked, so always include it properties[constants.AMPLITUDE_EVENT_PROP_ELEMENT_ID] = element.getAttribute('id') || ''; diff --git a/packages/plugin-autocapture-browser/src/helpers.ts b/packages/plugin-autocapture-browser/src/helpers.ts index 8f123afd5..964304371 100644 --- a/packages/plugin-autocapture-browser/src/helpers.ts +++ b/packages/plugin-autocapture-browser/src/helpers.ts @@ -1,5 +1,6 @@ /* eslint-disable no-restricted-globals */ -import { ElementInteractionsOptions, ActionType, isUrlMatchAllowlist } from '@amplitude/analytics-core'; +import { ElementInteractionsOptions, ActionType, isUrlMatchAllowlist, getGlobalScope } from '@amplitude/analytics-core'; +import * as constants from './constants'; export type JSONValue = string | number | boolean | null | { [x: string]: JSONValue } | Array; @@ -157,6 +158,24 @@ export const removeEmptyProperties = (properties: { [key: string]: unknown }): { }, {}); }; +export const getCurrentPageViewId = (): string | undefined => { + try { + const globalScope = getGlobalScope(); + /* istanbul ignore next */ + const raw = globalScope?.sessionStorage?.getItem(constants.PAGE_VIEW_SESSION_STORAGE_KEY); + if (!raw) { + return undefined; + } + const parsed = JSON.parse(raw) as { pageViewId?: unknown }; + if (typeof parsed.pageViewId === 'string') { + return parsed.pageViewId; + } + } catch { + // ignore storage access or JSON errors + } + return undefined; +}; + export const querySelectUniqueElements = (root: Element | Document, selectors: string[]): Element[] => { if (root && 'querySelectorAll' in root && typeof root.querySelectorAll === 'function') { const elementSet = selectors.reduce((elements: Set, selector) => { diff --git a/packages/plugin-autocapture-browser/src/libs/element-path.ts b/packages/plugin-autocapture-browser/src/libs/element-path.ts new file mode 100644 index 000000000..7174c51b2 --- /dev/null +++ b/packages/plugin-autocapture-browser/src/libs/element-path.ts @@ -0,0 +1,180 @@ +/* istanbul ignore file */ + +// Code is adapted from The Chromium Authors. +// Source: https://github.com/ChromeDevTools/devtools-frontend/blob/main/front_end/panels/elements/DOMPath.ts#L14 +// License: BSD-style license +// +// Copyright 2014 The Chromium Authors +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +class Step { + constructor(public value: string, public optimized: boolean) {} + toString() { + return this.value; + } +} + +export const cssPath = function (node: Element, optimized?: boolean): string { + // `node` is already an Element; this check is defensive. + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + const steps: Step[] = []; + let contextNode: Element | null = node; + + while (contextNode) { + const step = cssPathStep(contextNode, Boolean(optimized), contextNode === node); + if (!step) { + break; + } // bail out early + steps.push(step); + if (step.optimized) { + break; + } + contextNode = contextNode.parentElement; + } + + steps.reverse(); + return steps.join(' > '); +}; + +const cssPathStep = function (node: Element, optimized: boolean, isTargetNode: boolean): Step | null { + if (node.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + const id = node.getAttribute('id'); + if (optimized) { + if (id) { + return new Step(idSelector(id), true); + } + + const nodeNameLower = node.tagName.toLowerCase(); + if (nodeNameLower === 'body' || nodeNameLower === 'head' || nodeNameLower === 'html') { + return new Step(nodeNameLower, true); + } + } + + const nodeName = node.tagName.toLowerCase(); + + if (id) { + return new Step(nodeName + idSelector(id), true); + } + + const parent = node.parentNode; + if (!parent || parent.nodeType === Node.DOCUMENT_NODE) { + return new Step(nodeName, true); + } + + function prefixedElementClassNames(el: Element): string[] { + const classAttribute = el.getAttribute('class'); + if (!classAttribute) { + return []; + } + return classAttribute + .split(/\s+/g) + .filter(Boolean) + .map(function (name) { + // The prefix is required to store "__proto__" in a object-based map. + return '$' + name; + }); + } + + function idSelector(id: string): string { + return '#' + CSS.escape(id); + } + + const prefixedOwnClassNamesArray = prefixedElementClassNames(node); + let needsClassNames = false; + let needsNthChild = false; + let ownIndex = -1; + let elementIndex = -1; + + const siblings: HTMLCollectionOf = parent.children; + + for (let i = 0; siblings && (ownIndex === -1 || !needsNthChild) && i < siblings.length; ++i) { + const sibling = siblings[i]; + if (sibling.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + elementIndex += 1; + if (sibling === node) { + ownIndex = elementIndex; + continue; + } + if (needsNthChild) { + continue; + } + + if (sibling.tagName.toLowerCase() !== nodeName) { + continue; + } + + needsClassNames = true; + const ownClassNames = new Set(prefixedOwnClassNamesArray); + if (!ownClassNames.size) { + needsNthChild = true; + continue; + } + + const siblingClassNamesArray = prefixedElementClassNames(sibling); + for (let j = 0; j < siblingClassNamesArray.length; ++j) { + const siblingClass = siblingClassNamesArray[j]; + if (!ownClassNames.has(siblingClass)) { + continue; + } + ownClassNames.delete(siblingClass); + if (!ownClassNames.size) { + needsNthChild = true; + break; + } + } + } + + let result = nodeName; + if ( + isTargetNode && + nodeName.toLowerCase() === 'input' && + node.getAttribute('type') && + !node.getAttribute('id') && + !node.getAttribute('class') + ) { + result += '[type=' + CSS.escape(node.getAttribute('type') || '') + ']'; + } + if (needsNthChild) { + result += ':nth-child(' + String(ownIndex + 1) + ')'; + } else if (needsClassNames) { + for (const prefixedName of prefixedOwnClassNamesArray) { + result += '.' + CSS.escape(prefixedName.slice(1)); + } + } + + return new Step(result, false); +}; diff --git a/packages/plugin-autocapture-browser/src/libs/finder.ts b/packages/plugin-autocapture-browser/src/libs/finder.ts deleted file mode 100644 index 3fec33ded..000000000 --- a/packages/plugin-autocapture-browser/src/libs/finder.ts +++ /dev/null @@ -1,325 +0,0 @@ -/* istanbul ignore file */ - -// License: MIT -// Author: Anton Medvedev -// Source: https://github.com/antonmedv/finder - -type Knot = { - name: string; - penalty: number; - level?: number; -}; - -type Path = Knot[]; - -export type Options = { - root: Element; - idName: (name: string) => boolean; - className: (name: string) => boolean; - tagName: (name: string) => boolean; - attr: (name: string, value: string) => boolean; - seedMinLength: number; - optimizedMinLength: number; - threshold: number; - maxNumberOfTries: number; -}; - -let config: Options; -let rootDocument: Document | Element; - -export function finder(input: Element, options?: Partial) { - if (input.nodeType !== Node.ELEMENT_NODE) { - throw new Error(`Can't generate CSS selector for non-element node type.`); - } - if ('html' === input.tagName.toLowerCase()) { - return 'html'; - } - const defaults: Options = { - root: document.body, - idName: (_name: string) => true, - className: (_name: string) => true, - tagName: (_name: string) => true, - attr: (_name: string, _value: string) => false, - seedMinLength: 1, - optimizedMinLength: 2, - threshold: 1000, - maxNumberOfTries: 10000, - }; - - config = { ...defaults, ...options }; - rootDocument = findRootDocument(config.root, defaults); - - let path = bottomUpSearch(input, 'all', () => - bottomUpSearch(input, 'two', () => bottomUpSearch(input, 'one', () => bottomUpSearch(input, 'none'))), - ); - - if (path) { - const optimized = sort(optimize(path, input)); - if (optimized.length > 0) { - path = optimized[0]; - } - return selector(path); - } else { - throw new Error(`Selector was not found.`); - } -} - -function findRootDocument(rootNode: Element | Document, defaults: Options) { - if (rootNode.nodeType === Node.DOCUMENT_NODE) { - return rootNode; - } - if (rootNode === defaults.root) { - return rootNode.ownerDocument; - } - return rootNode; -} - -function bottomUpSearch( - input: Element, - limit: 'all' | 'two' | 'one' | 'none', - fallback?: () => Path | null, -): Path | null { - let path: Path | null = null; - const stack: Knot[][] = []; - let current: Element | null = input; - let i = 0; - while (current) { - let level: Knot[] = maybe(id(current)) || - maybe(...attr(current)) || - maybe(...classNames(current)) || - maybe(tagName(current)) || [any()]; - const nth = index(current); - if (limit == 'all') { - if (nth) { - level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); - } - } else if (limit == 'two') { - level = level.slice(0, 1); - if (nth) { - level = level.concat(level.filter(dispensableNth).map((node) => nthChild(node, nth))); - } - } else if (limit == 'one') { - const [node] = (level = level.slice(0, 1)); - if (nth && dispensableNth(node)) { - level = [nthChild(node, nth)]; - } - } else if (limit == 'none') { - level = [any()]; - if (nth) { - level = [nthChild(level[0], nth)]; - } - } - for (const node of level) { - node.level = i; - } - stack.push(level); - if (stack.length >= config.seedMinLength) { - path = findUniquePath(stack, fallback); - if (path) { - break; - } - } - current = current.parentElement; - i++; - } - if (!path) { - path = findUniquePath(stack, fallback); - } - if (!path && fallback) { - return fallback(); - } - return path; -} - -function findUniquePath(stack: Knot[][], fallback?: () => Path | null): Path | null { - // Check first the total number of combinations first since generating the combinations can cause memory exhaustion - const numCombinations = stack.reduce((acc, i) => acc * i.length, 1); - if (numCombinations > config.threshold) { - return fallback ? fallback() : null; - } - - const paths = sort(combinations(stack)); - for (const candidate of paths) { - if (unique(candidate)) { - return candidate; - } - } - return null; -} - -function selector(path: Path): string { - let node = path[0]; - let query = node.name; - for (let i = 1; i < path.length; i++) { - const level = path[i].level || 0; - if (node.level === level - 1) { - query = `${path[i].name} > ${query}`; - } else { - query = `${path[i].name} ${query}`; - } - node = path[i]; - } - return query; -} - -function penalty(path: Path): number { - return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0); -} - -function unique(path: Path) { - const css = selector(path); - switch (rootDocument.querySelectorAll(css).length) { - case 0: - throw new Error(`Can't select any node with this selector: ${css}`); - case 1: - return true; - default: - return false; - } -} - -function id(input: Element): Knot | null { - const elementId = input.getAttribute('id'); - if (elementId && config.idName(elementId)) { - return { - name: '#' + CSS.escape(elementId), - penalty: 0, - }; - } - return null; -} - -function attr(input: Element): Knot[] { - const attrs = Array.from(input.attributes).filter((attr) => config.attr(attr.name, attr.value)); - return attrs.map( - (attr): Knot => ({ - name: `[${CSS.escape(attr.name)}="${CSS.escape(attr.value)}"]`, - penalty: 0.5, - }), - ); -} - -function classNames(input: Element): Knot[] { - const names = Array.from(input.classList).filter(config.className); - return names.map( - (name): Knot => ({ - name: '.' + CSS.escape(name), - penalty: 1, - }), - ); -} - -function tagName(input: Element): Knot | null { - const name = input.tagName.toLowerCase(); - if (config.tagName(name)) { - return { - name, - penalty: 2, - }; - } - return null; -} - -function any(): Knot { - return { - name: '*', - penalty: 3, - }; -} - -function index(input: Element): number | null { - const parent = input.parentNode; - if (!parent) { - return null; - } - let child = parent.firstChild; - if (!child) { - return null; - } - let i = 0; - while (child) { - if (child.nodeType === Node.ELEMENT_NODE) { - i++; - } - if (child === input) { - break; - } - child = child.nextSibling; - } - return i; -} - -function nthChild(node: Knot, i: number): Knot { - return { - name: node.name + `:nth-child(${i})`, - penalty: node.penalty + 1, - }; -} - -function dispensableNth(node: Knot) { - return node.name !== 'html' && !node.name.startsWith('#'); -} - -function maybe(...level: (Knot | null)[]): Knot[] | null { - const list = level.filter(notEmpty); - if (list.length > 0) { - return list; - } - return null; -} - -function notEmpty(value: T | null | undefined): value is T { - return value !== null && value !== undefined; -} - -function* combinations(stack: Knot[][], path: Knot[] = []): Generator { - if (stack.length > 0) { - for (const node of stack[0]) { - yield* combinations(stack.slice(1, stack.length), path.concat(node)); - } - } else { - yield path; - } -} - -function sort(paths: Iterable): Path[] { - return [...paths].sort((a, b) => penalty(a) - penalty(b)); -} - -type Scope = { - counter: number; - visited: Map; -}; - -function* optimize( - path: Path, - input: Element, - scope: Scope = { - counter: 0, - visited: new Map(), - }, -): Generator { - if (path.length > 2 && path.length > config.optimizedMinLength) { - for (let i = 1; i < path.length - 1; i++) { - if (scope.counter > config.maxNumberOfTries) { - return; // Okay At least I tried! - } - scope.counter += 1; - const newPath = [...path]; - newPath.splice(i, 1); - const newPathKey = selector(newPath); - if (scope.visited.has(newPathKey)) { - return; - } - if (unique(newPath) && same(newPath, input)) { - yield newPath; - scope.visited.set(newPathKey, true); - yield* optimize(newPath, input, scope); - } - } - } -} - -function same(path: Path, input: Element) { - return rootDocument.querySelector(selector(path)) === input; -} diff --git a/packages/plugin-autocapture-browser/src/libs/messenger.ts b/packages/plugin-autocapture-browser/src/libs/messenger.ts index cffb960cf..f70303e06 100644 --- a/packages/plugin-autocapture-browser/src/libs/messenger.ts +++ b/packages/plugin-autocapture-browser/src/libs/messenger.ts @@ -4,9 +4,13 @@ import { AMPLITUDE_ORIGIN, AMPLITUDE_VISUAL_TAGGING_SELECTOR_SCRIPT_URL, AMPLITUDE_VISUAL_TAGGING_HIGHLIGHT_CLASS, + AMPLITUDE_BACKGROUND_CAPTURE_SCRIPT_URL, } from '../constants'; import { asyncLoadScript, generateUniqueId } from '../helpers'; import { ILogger, Messenger, ActionType } from '@amplitude/analytics-core'; +// Inline background-capture implementation (disabled) uses rrweb snapshot. +// eslint-disable-next-line import/no-extraneous-dependencies +// import { snapshot } from '@amplitude/rrweb-snapshot'; import { VERSION } from '../version'; import { DataExtractor } from '../data-extractor'; @@ -19,7 +23,11 @@ export type Action = | 'close-visual-tagging-selector' | 'element-selected' | 'track-selector-mode-changed' - | 'track-selector-moved'; + | 'track-selector-moved' + | 'initialize-background-capture' + | 'close-background-capture' + | 'background-capture-loaded' + | 'background-capture-complete'; interface InitializeVisualTaggingSelectorData { actionType: ActionType; @@ -53,6 +61,10 @@ export type ActionData = { 'element-selected': ElementSelectedData; 'track-selector-mode-changed': TrackSelectorModeChangedData; 'track-selector-moved': TrackSelectorMovedData; + 'initialize-background-capture': null | undefined; + 'close-background-capture': null | undefined; + 'background-capture-loaded': null | undefined; + 'background-capture-complete': { [key: string]: string | null }; }; export interface Message { @@ -155,7 +167,8 @@ export class WindowMessenger implements Messenger { this.endpoint = endpoint; } let amplitudeVisualTaggingSelectorInstance: any = null; - + let amplitudeBackgroundCaptureInstance: any = null; + this.logger?.debug?.('Setting up messenger'); // Attach Event Listener to listen for messages from the parent window window.addEventListener('message', (event) => { this.logger?.debug?.('Message received: ', JSON.stringify(event)); @@ -214,9 +227,27 @@ export class WindowMessenger implements Messenger { .catch(() => { this.logger?.warn('Failed to initialize visual tagging selector'); }); + } else if (action === 'initialize-background-capture') { + this.logger?.debug?.('Initializing background capture (external script)'); + asyncLoadScript(new URL(AMPLITUDE_BACKGROUND_CAPTURE_SCRIPT_URL, this.endpoint).toString()) + .then(() => { + this.logger?.debug?.('Background capture script loaded (external)'); + // eslint-disable-next-line + amplitudeBackgroundCaptureInstance = (window as any)?.amplitudeBackgroundCapture?.({ + messenger: this, + onBackgroundCapture: this.onBackgroundCapture, + }); + this.notify({ action: 'background-capture-loaded' }); + }) + .catch(() => { + this.logger?.warn('Failed to initialize background capture'); + }); } else if (action === 'close-visual-tagging-selector') { // eslint-disable-next-line amplitudeVisualTaggingSelectorInstance?.close?.(); + } else if (action === 'close-background-capture') { + // eslint-disable-next-line + amplitudeBackgroundCaptureInstance?.close?.(); } } }); @@ -236,4 +267,11 @@ export class WindowMessenger implements Messenger { this.notify({ action: 'track-selector-moved', data: properties }); } }; + + private onBackgroundCapture = (type: string, backgroundCaptureData: { [key: string]: string | number | null }) => { + if (type === 'background-capture-complete') { + this.logger?.debug?.('Background capture complete'); + this.notify({ action: 'background-capture-complete', data: backgroundCaptureData }); + } + }; } diff --git a/packages/plugin-autocapture-browser/src/observables.ts b/packages/plugin-autocapture-browser/src/observables.ts index 5757c60d1..b39a0f561 100644 --- a/packages/plugin-autocapture-browser/src/observables.ts +++ b/packages/plugin-autocapture-browser/src/observables.ts @@ -1,3 +1,4 @@ +import { TimestampedEvent } from './helpers'; import { Observable, consoleObserver, getGlobalScope, merge } from '@amplitude/analytics-core'; /* eslint-disable-next-line no-restricted-globals */ @@ -31,9 +32,23 @@ export const createClickObservable = ( const handler = (event: MouseEvent | PointerEvent) => { observer.next(event); }; - globalScope.document.addEventListener(clickType, handler, { capture: true }); + + getGlobalScope()?.document.addEventListener(clickType, handler, { capture: true }); return () => { - globalScope.document.removeEventListener(clickType, handler, { capture: true }); + getGlobalScope()?.document.removeEventListener(clickType, handler, { capture: true }); + }; + }); +}; + +export const createScrollObservable = (): Observable => { + return new Observable((observer) => { + const handler = (event: Event) => { + observer.next(event); + }; + + getGlobalScope()?.window.addEventListener('scroll', handler); + return () => { + getGlobalScope()?.window.removeEventListener('scroll', handler); }; }); }; @@ -55,6 +70,54 @@ const createConsoleErrorObservable = (): Observable => { }); }; +// Tracks when a trackedelement is exposed to the viewport +export const createExposureObservable = ( + mutationObservable: Observable>, + selectorAllowlist: string[], +): Observable => { + return new Observable((observer) => { + const globalScope = getGlobalScope(); + + if (!globalScope?.IntersectionObserver) { + console.log('IntersectionObserver not supported'); + return () => { + return; + }; + } + + const intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + observer.next(entry as unknown as Event); + }); + }, + { + root: null, // viewport + rootMargin: '0px', // start exactly at the viewport edge + threshold: 1.0, // trigger when 100% of the element is visible + }, + ); + + // Observe initial elements + const selectorString = selectorAllowlist.join(','); + /* istanbul ignore next */ + const initialElements = globalScope?.document.querySelectorAll(selectorString) ?? []; + initialElements.forEach((element) => { + intersectionObserver.observe(element); + }); + + // Use mutation observable to observe new elements + mutationObservable.subscribe(({ event }) => + event.forEach(({ addedNodes }) => + addedNodes.forEach((node) => node instanceof Element && intersectionObserver.observe(node)), + ), + ); + + return () => { + intersectionObserver.disconnect(); + }; + }); +}; const createUnhandledErrorObservable = (): Observable => { return new Observable((observer) => { const handler = (event: ErrorEvent) => { diff --git a/packages/plugin-autocapture-browser/src/version.ts b/packages/plugin-autocapture-browser/src/version.ts index 9a6358463..e2f01ef1d 100644 --- a/packages/plugin-autocapture-browser/src/version.ts +++ b/packages/plugin-autocapture-browser/src/version.ts @@ -1 +1 @@ -export const VERSION = '1.19.0'; +export const VERSION = '1.20.0-feat-zoning-alpha.0'; diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts index 8f95afa3e..b866c48f9 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-action-clicks.test.ts @@ -175,6 +175,7 @@ describe('action clicks:', () => { ], '[Amplitude] Element ID': 'addDivButton', '[Amplitude] Element Parent Label': 'Card Title', + '[Amplitude] Element Path': 'div#addDivButton', '[Amplitude] Element Position Left': 0, '[Amplitude] Element Position Top': 0, '[Amplitude] Element Tag': 'div', @@ -315,8 +316,9 @@ describe('action clicks:', () => { (window.navigation as any).dispatchEvent(new Event('navigate')); await new Promise((r) => setTimeout(r, TESTING_DEBOUNCE_TIME + 503)); - expect(track).toHaveBeenCalledTimes(1); - expect(track).toHaveBeenNthCalledWith(1, '[Amplitude] Element Clicked', expect.objectContaining({})); + // once for the page view end event, once for the click event + expect(track).toHaveBeenCalledTimes(2); + expect(track).toHaveBeenNthCalledWith(2, '[Amplitude] Element Clicked', expect.objectContaining({})); }); }); }); diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-exposure.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-exposure.test.ts new file mode 100644 index 000000000..58f6464ae --- /dev/null +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-exposure.test.ts @@ -0,0 +1,234 @@ +import { trackExposure } from '../../src/autocapture/track-exposure'; +import { AllWindowObservables, ObservablesEnum } from '../../src/autocapture-plugin'; +import { DataExtractor } from '../../src'; +import { DEFAULT_EXPOSURE_DURATION } from '@amplitude/analytics-core'; + +describe('trackExposure', () => { + let exposureObservable: any; + let allObservables: AllWindowObservables; + let onExposure: jest.Mock; + let unsubscribe: () => void; + let reset: () => void; + let observers: Array<(val: any) => void> = []; + + beforeEach(() => { + jest.useFakeTimers(); + onExposure = jest.fn(); + observers = []; + + // Mock Observable implementation + exposureObservable = { + subscribe: (fn: (val: any) => void) => { + observers.push(fn); + return { + unsubscribe: () => { + observers = observers.filter((o) => o !== fn); + }, + }; + }, + }; + + allObservables = { + [ObservablesEnum.ExposureObservable]: exposureObservable, + } as any; + + const dataExtractor = new DataExtractor({}); + const result = trackExposure({ + allObservables, + onExposure, + dataExtractor, + }); + unsubscribe = result.unsubscribe; + reset = result.reset; + }); + + afterEach(() => { + unsubscribe(); + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + const triggerExposure = (entry: Partial) => { + observers.forEach((observer) => observer(entry)); + }; + + test('should mark element as exposed after 2 seconds of visibility', () => { + const element = document.createElement('div'); + element.id = 'test-div'; + + triggerExposure({ + isIntersecting: true, + target: element, + intersectionRatio: 1.0, + }); + + // Should not be exposed yet + expect(onExposure).not.toHaveBeenCalled(); + + // Fast forward 2 seconds + jest.advanceTimersByTime(2000); + + expect(onExposure).toHaveBeenCalledWith('div#test-div'); + }); + + test('should not mark element as exposed if it becomes invisible before timeout (1 second)', () => { + const element = document.createElement('div'); + element.id = 'test-div-cancel'; + + triggerExposure({ + isIntersecting: true, + target: element, + intersectionRatio: 1.0, + }); + + jest.advanceTimersByTime(50); + + // Element leaves viewport + triggerExposure({ + isIntersecting: false, + target: element, + intersectionRatio: 0, + }); + + jest.advanceTimersByTime(50); + + expect(onExposure).not.toHaveBeenCalled(); + }); + + test('should not re-expose already exposed element', () => { + const element = document.createElement('div'); + element.id = 'test-div-repeat'; + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + // First exposure + triggerExposure({ + isIntersecting: true, + target: element, + intersectionRatio: 1.0, + }); + + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION * 1.5); + expect(onExposure).toHaveBeenCalledWith('div#test-div-repeat'); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + + // Reset spy + setTimeoutSpy.mockClear(); + + // Element leaves and comes back + triggerExposure({ + isIntersecting: false, + target: element, + intersectionRatio: 0, + }); + + triggerExposure({ + isIntersecting: true, + target: element, + intersectionRatio: 1.0, + }); + + // Should not start a new timer because it is already exposed in the internal map + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + test('should handle multiple elements independently', () => { + const element1 = document.createElement('div'); + element1.id = 'div-1'; + const element2 = document.createElement('div'); + element2.id = 'div-2'; + + // Start element 1 + triggerExposure({ + isIntersecting: true, + target: element1, + intersectionRatio: 1.0, + }); + + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION / 2); + + // Start element 2 + triggerExposure({ + isIntersecting: true, + target: element2, + intersectionRatio: 1.0, + }); + + // Element 1 finishes + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION / 2); + expect(onExposure).toHaveBeenCalledWith('div#div-1'); + expect(onExposure).not.toHaveBeenCalledWith('div#div-2'); + + // Element 2 finishes + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION / 2); + expect(onExposure).toHaveBeenCalledWith('div#div-2'); + }); + + test('should clear timer when element leaves viewport (intersection check)', () => { + const element = document.createElement('div'); + element.id = 'test-div-leave'; + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + triggerExposure({ + isIntersecting: true, + target: element, + intersectionRatio: 1.0, + }); + + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION / 2); + + triggerExposure({ + isIntersecting: false, + target: element, + intersectionRatio: 0.5, // < 1.0 + }); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION * 1.5); + expect(onExposure).not.toHaveBeenCalled(); + }); + + test('should clear all timers and exposure map on reset', () => { + const element1 = document.createElement('div'); + element1.id = 'reset-div-1'; + const element2 = document.createElement('div'); + element2.id = 'reset-div-2'; + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + // Start element 2 exposure and complete it + triggerExposure({ + isIntersecting: true, + target: element2, + intersectionRatio: 1.0, + }); + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION * 1.5); + expect(onExposure).toHaveBeenCalledWith('div#reset-div-2'); + onExposure.mockClear(); + + // Start element 1 exposure (will be pending) + triggerExposure({ + isIntersecting: true, + target: element1, + intersectionRatio: 1.0, + }); + + // Call reset + reset(); + + // Expect pending timer for element 1 to be cleared + expect(clearTimeoutSpy).toHaveBeenCalled(); + + // Fast forward to see if pending timer fires (should not) + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION * 1.5); + expect(onExposure).not.toHaveBeenCalledWith('div#reset-div-1'); + + // Re-expose element 2 (should work again because map was cleared) + triggerExposure({ + isIntersecting: true, + target: element2, + intersectionRatio: 1.0, + }); + jest.advanceTimersByTime(DEFAULT_EXPOSURE_DURATION * 1.5); + expect(onExposure).toHaveBeenCalledWith('div#reset-div-2'); + }); +}); diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-scroll.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-scroll.test.ts new file mode 100644 index 000000000..43df79254 --- /dev/null +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-scroll.test.ts @@ -0,0 +1,132 @@ +import { trackScroll } from '../../src/autocapture/track-scroll'; +import { ObservablesEnum } from '../../src/autocapture-plugin'; +import { BrowserClient } from '@amplitude/analytics-core'; + +describe('trackScroll', () => { + let scrollObservable: any; + let allObservables: any; + let unsubscribe: () => void; + let triggerScroll: () => void; + let amplitude: BrowserClient; + + beforeEach(() => { + // Mock Observable + const observers: Array<() => void> = []; + scrollObservable = { + subscribe: jest.fn((fn) => { + observers.push(fn); + return { + unsubscribe: jest.fn(() => { + const index = observers.indexOf(fn); + if (index > -1) observers.splice(index, 1); + }), + }; + }), + }; + + triggerScroll = () => { + observers.forEach((fn) => fn()); + }; + + allObservables = { + [ObservablesEnum.ScrollObservable]: scrollObservable, + }; + + amplitude = {} as BrowserClient; // unused + + // Reset window scroll properties + Object.defineProperty(window, 'scrollX', { value: 0, writable: true }); + Object.defineProperty(window, 'scrollY', { value: 0, writable: true }); + Object.defineProperty(window, 'pageXOffset', { value: 0, writable: true }); + Object.defineProperty(window, 'pageYOffset', { value: 0, writable: true }); + }); + + afterEach(() => { + if (unsubscribe) unsubscribe(); + jest.clearAllMocks(); + }); + + // Helper to set window scroll + const setScroll = (x: number, y: number) => { + Object.defineProperty(window, 'scrollX', { value: x, writable: true }); + Object.defineProperty(window, 'scrollY', { value: y, writable: true }); + Object.defineProperty(window, 'pageXOffset', { value: x, writable: true }); + Object.defineProperty(window, 'pageYOffset', { value: y, writable: true }); + }; + + test('should update state on scroll', () => { + const tracker = trackScroll({ + amplitude, + allObservables, + }); + unsubscribe = tracker.unsubscribe; + + setScroll(100, 200); + triggerScroll(); + + expect(tracker.getState().maxX).toBe(100); + expect(tracker.getState().maxY).toBe(200); + }); + + test('should keep max values when scrolling back', () => { + const tracker = trackScroll({ + amplitude, + allObservables, + }); + unsubscribe = tracker.unsubscribe; + + // Scroll down/right + setScroll(100, 200); + triggerScroll(); + expect(tracker.getState().maxX).toBe(100); + expect(tracker.getState().maxY).toBe(200); + + // Scroll back up/left + setScroll(50, 50); + triggerScroll(); + expect(tracker.getState().maxX).toBe(100); // Should remain 100 + expect(tracker.getState().maxY).toBe(200); // Should remain 200 + + // Scroll further down/right + setScroll(150, 300); + triggerScroll(); + expect(tracker.getState().maxX).toBe(150); + expect(tracker.getState().maxY).toBe(300); + }); + + test('should handle missing scroll properties gracefully (fallback to 0)', () => { + const tracker = trackScroll({ + amplitude, + allObservables, + }); + unsubscribe = tracker.unsubscribe; + + // Simulate environment where properties are missing or undefined + Object.defineProperty(window, 'scrollX', { value: undefined, writable: true }); + Object.defineProperty(window, 'scrollY', { value: undefined, writable: true }); + Object.defineProperty(window, 'pageXOffset', { value: undefined, writable: true }); + Object.defineProperty(window, 'pageYOffset', { value: undefined, writable: true }); + + triggerScroll(); + + expect(tracker.getState().maxX).toBe(0); + expect(tracker.getState().maxY).toBe(0); + }); + + test('should reset state', () => { + const tracker = trackScroll({ + amplitude, + allObservables, + }); + unsubscribe = tracker.unsubscribe; + + setScroll(100, 200); + triggerScroll(); + expect(tracker.getState().maxX).toBe(100); + expect(tracker.getState().maxY).toBe(200); + + tracker.reset(); + expect(tracker.getState().maxX).toBe(0); + expect(tracker.getState().maxY).toBe(0); + }); +}); diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts new file mode 100644 index 000000000..62c6ef799 --- /dev/null +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/viewport-content-updated.test.ts @@ -0,0 +1,429 @@ +import { autocapturePlugin } from '../../src/autocapture-plugin'; +import { BrowserClient, BrowserConfig, ILogger } from '@amplitude/analytics-core'; +import { createMockBrowserClient } from '../mock-browser-client'; +import { trackExposure } from '../../src/autocapture/track-exposure'; +import { + fireViewportContentUpdated, + ScrollTracker, + ExposureTracker, +} from '../../src/autocapture/track-viewport-content-updated'; +import * as constants from '../../src/constants'; + +// Mock trackExposure to capture onExposure callback +jest.mock('../../src/autocapture/track-exposure', () => ({ + trackExposure: jest.fn(), +})); + +// Mock fireViewportContentUpdated to verify calls +jest.mock('../../src/autocapture/track-viewport-content-updated', () => { + const originalModule = jest.requireActual( + '../../src/autocapture/track-viewport-content-updated', + ); + return { + ...originalModule, + fireViewportContentUpdated: jest.fn((...args: Parameters) => + originalModule.fireViewportContentUpdated(...args), + ), + }; +}); + +describe('autocapturePlugin - Viewport Content Updated (Exposure)', () => { + let plugin: any; + let instance: BrowserClient; + let track: jest.SpyInstance; + let onExposureCallback: (elementPath: string) => void; + let trackExposureMock: jest.Mock; + let fireViewportContentUpdatedMock: jest.Mock; + + const TESTING_DEBOUNCE_TIME = 0; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.spyOn(console, 'log').mockImplementation(jest.fn()); + + trackExposureMock = trackExposure as unknown as jest.Mock; + trackExposureMock.mockImplementation(({ onExposure }) => { + onExposureCallback = onExposure; + return { + unsubscribe: jest.fn(), + reset: jest.fn(), + }; + }); + + fireViewportContentUpdatedMock = fireViewportContentUpdated as unknown as jest.Mock; + + const loggerProvider = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + } as unknown as ILogger; + + plugin = autocapturePlugin({ debounceTime: TESTING_DEBOUNCE_TIME }); + instance = createMockBrowserClient(); + await instance.init('API_KEY', 'USER_ID').promise; + track = jest.spyOn(instance, 'track').mockImplementation(jest.fn()); + + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin.setup(config as BrowserConfig, instance); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + window.sessionStorage.clear(); + plugin.teardown(); + }); + + test('should trigger Viewport Content Updated when exposed elements exceed 18k chars', async () => { + // Verify onExposure was captured + expect(onExposureCallback).toBeDefined(); + + // 1. Add a small element, should not trigger track + onExposureCallback('small-element'); + expect(fireViewportContentUpdatedMock).not.toHaveBeenCalled(); + + // 2. Add a very large element path to exceed 18k chars + // We need to exceed 18000 characters in JSON.stringify(array) + // The array will be ["small-element", "large..."] + // We can just add one massive string. + const largeString = 'a'.repeat(19000); + onExposureCallback(largeString); + + // Should trigger track + expect(fireViewportContentUpdatedMock).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': expect.arrayContaining(['small-element', largeString]), + }), + ); + + // 3. Verify the batch is cleared after tracking + track.mockClear(); + fireViewportContentUpdatedMock.mockClear(); + + // Advance timers to allow pageViewEndFired to reset (100ms timeout in handleViewportContentUpdated) + jest.advanceTimersByTime(150); + + // Add another small element + onExposureCallback('another-small-element'); + + // Should not trigger again immediately + expect(fireViewportContentUpdatedMock).not.toHaveBeenCalled(); + + // But if we trigger page view end (e.g. via beforeunload), it should flush the new batch + window.dispatchEvent(new Event('beforeunload')); + + expect(fireViewportContentUpdatedMock).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': expect.arrayContaining(['another-small-element']), + }), + ); + }); + + test('should not re-add already exposed elements to the current batch', async () => { + // 1. Add an element + onExposureCallback('element-1'); + + // 2. Add the same element again + onExposureCallback('element-1'); + + // 3. Force flush via beforeunload + window.dispatchEvent(new Event('beforeunload')); + + // Should only be in the array once + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': ['element-1'], + }), + ); + }); + + test('should include page view ID when available', async () => { + window.sessionStorage.setItem( + constants.PAGE_VIEW_SESSION_STORAGE_KEY, + JSON.stringify({ pageViewId: 'pv-test-123' }), + ); + + onExposureCallback('element-1'); + window.dispatchEvent(new Event('beforeunload')); + + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': ['element-1'], + '[Amplitude] Page View ID': 'pv-test-123', + }), + ); + }); + + test('should not include page view ID when sessionStorage contains invalid JSON', async () => { + window.sessionStorage.setItem(constants.PAGE_VIEW_SESSION_STORAGE_KEY, 'invalid-json{not-valid'); + + onExposureCallback('element-1'); + window.dispatchEvent(new Event('beforeunload')); + + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': ['element-1'], + }), + ); + + // Verify Page View ID is NOT in the event properties + const trackCall = track.mock.calls[0]; + expect(trackCall[1]).not.toHaveProperty('[Amplitude] Page View ID'); + }); + + test('should call handleViewportContentUpdated with isPageEnd=true on beforeunload', async () => { + onExposureCallback('element-1'); + window.dispatchEvent(new Event('beforeunload')); + + expect(fireViewportContentUpdatedMock).toHaveBeenCalledTimes(1); + expect(fireViewportContentUpdatedMock).toHaveBeenCalledWith( + expect.objectContaining({ + isPageEnd: true, + }), + ); + }); + + test('should call handleViewportContentUpdated with isPageEnd=false when exposure exceeds 18k chars', async () => { + // Add a very large element path to exceed 18k chars + const largeString = 'a'.repeat(19000); + onExposureCallback(largeString); + + expect(fireViewportContentUpdatedMock).toHaveBeenCalledTimes(1); + expect(fireViewportContentUpdatedMock).toHaveBeenCalledWith( + expect.objectContaining({ + isPageEnd: false, + }), + ); + }); +}); + +describe('fireViewportContentUpdated - exposureTracker optional chaining', () => { + let mockAmplitude: BrowserClient; + let mockScrollTracker: ScrollTracker; + + beforeEach(async () => { + mockAmplitude = createMockBrowserClient(); + await mockAmplitude.init('API_KEY', 'USER_ID').promise; + jest.spyOn(mockAmplitude, 'track').mockImplementation(jest.fn()); + + mockScrollTracker = { + getState: jest.fn().mockReturnValue({ maxX: 100, maxY: 200 }), + reset: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should handle undefined exposureTracker gracefully when isPageEnd is true', () => { + const currentElementExposed = new Set(['element-1']); + const elementExposedForPage = new Set(['element-1']); + + // Call with exposureTracker as undefined and isPageEnd as true + expect(() => { + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: true, + lastScroll: { maxX: undefined, maxY: undefined }, + }); + }).not.toThrow(); + + // Verify scrollTracker.reset was still called + expect(mockScrollTracker.reset).toHaveBeenCalled(); + // Verify elementExposedForPage was cleared + expect(elementExposedForPage.size).toBe(0); + }); + + test('should call exposureTracker.reset when exposureTracker is defined and isPageEnd is true', () => { + const currentElementExposed = new Set(['element-1']); + const elementExposedForPage = new Set(['element-1']); + const mockExposureTracker: ExposureTracker = { + reset: jest.fn(), + }; + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: mockExposureTracker, + isPageEnd: true, + lastScroll: { maxX: undefined, maxY: undefined }, + }); + + // Verify exposureTracker.reset was called + expect(mockExposureTracker.reset).toHaveBeenCalled(); + // Verify scrollTracker.reset was also called + expect(mockScrollTracker.reset).toHaveBeenCalled(); + }); + + test('should not call exposureTracker.reset when isPageEnd is false', () => { + const currentElementExposed = new Set(['element-1']); + const elementExposedForPage = new Set(['element-1']); + const mockExposureTracker: ExposureTracker = { + reset: jest.fn(), + }; + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: mockExposureTracker, + isPageEnd: false, + lastScroll: { maxX: undefined, maxY: undefined }, + }); + + // Verify exposureTracker.reset was NOT called + expect(mockExposureTracker.reset).not.toHaveBeenCalled(); + // Verify scrollTracker.reset was also NOT called + expect(mockScrollTracker.reset).not.toHaveBeenCalled(); + }); +}); + +describe('fireViewportContentUpdated - early return when no changes', () => { + let mockAmplitude: BrowserClient; + let mockScrollTracker: ScrollTracker; + let trackSpy: jest.SpyInstance; + + beforeEach(async () => { + mockAmplitude = createMockBrowserClient(); + await mockAmplitude.init('API_KEY', 'USER_ID').promise; + trackSpy = jest.spyOn(mockAmplitude, 'track').mockImplementation(jest.fn()); + + mockScrollTracker = { + getState: jest.fn().mockReturnValue({ maxX: 100, maxY: 200 }), + reset: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should not track when no elements exposed and scroll position unchanged', () => { + const currentElementExposed = new Set(); + const elementExposedForPage = new Set(); + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: false, + lastScroll: { maxX: 100, maxY: 200 }, // Same as scrollTracker state + }); + + // Should NOT call track because no elements exposed and scroll is same + expect(trackSpy).not.toHaveBeenCalled(); + }); + + test('should track when elements are exposed even if scroll position unchanged', () => { + const currentElementExposed = new Set(['element-1']); + const elementExposedForPage = new Set(['element-1']); + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: false, + lastScroll: { maxX: 100, maxY: 200 }, // Same as scrollTracker state + }); + + // Should call track because there are exposed elements + expect(trackSpy).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': ['element-1'], + }), + ); + }); + + test('should track when maxX changed even if no elements exposed', () => { + const currentElementExposed = new Set(); + const elementExposedForPage = new Set(); + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: false, + lastScroll: { maxX: 50, maxY: 200 }, // Different maxX + }); + + // Should call track because maxX changed + expect(trackSpy).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': [], + }), + ); + }); + + test('should track when maxY changed even if no elements exposed', () => { + const currentElementExposed = new Set(); + const elementExposedForPage = new Set(); + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: false, + lastScroll: { maxX: 100, maxY: 150 }, // Different maxY + }); + + // Should call track because maxY changed + expect(trackSpy).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': [], + }), + ); + }); + + test('should track when lastScroll has undefined values', () => { + const currentElementExposed = new Set(); + const elementExposedForPage = new Set(); + + fireViewportContentUpdated({ + amplitude: mockAmplitude, + scrollTracker: mockScrollTracker, + currentElementExposed, + elementExposedForPage, + exposureTracker: undefined, + isPageEnd: false, + lastScroll: { maxX: undefined, maxY: undefined }, // First call, undefined values + }); + + // Should call track because undefined !== 100 and undefined !== 200 + expect(trackSpy).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': [], + }), + ); + }); +}); diff --git a/packages/plugin-autocapture-browser/test/data-extractor.test.ts b/packages/plugin-autocapture-browser/test/data-extractor.test.ts index f971e57c8..e065d1d0d 100644 --- a/packages/plugin-autocapture-browser/test/data-extractor.test.ts +++ b/packages/plugin-autocapture-browser/test/data-extractor.test.ts @@ -354,6 +354,26 @@ describe('data extractor', () => { }); }); + describe('getElementPath', () => { + test('should return empty string when element is null', () => { + const result = dataExtractor.getElementPath(null); + expect(result).toEqual(''); + }); + + test('should return CSS path when element is valid', () => { + document.getElementsByTagName('body')[0].innerHTML = ` +
+ +
+ `; + + const button = document.getElementById('test-button'); + const result = dataExtractor.getElementPath(button); + + expect(result).toEqual('button#test-button'); + }); + }); + describe('getEventTagProps', () => { beforeAll(() => { Object.defineProperty(window, 'location', { @@ -993,6 +1013,13 @@ describe('data extractor', () => { expect.any(Number), ); + dataExtractorWithDiagnostics.getElementPath(target); + + expect(mockDiagnosticsClient.recordHistogram).toHaveBeenCalledWith( + 'autocapturePlugin.getElementPath', + expect.any(Number), + ); + // Verify that the recorded value is a positive number (execution time) // eslint-disable-next-line const recordedValue = mockDiagnosticsClient.recordHistogram.mock.calls[0][1]; diff --git a/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts b/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts index 27ec772b4..00e6fa651 100644 --- a/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts +++ b/packages/plugin-autocapture-browser/test/default-event-tracking-advanced.test.ts @@ -123,6 +123,77 @@ describe('autoTrackingPlugin', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect((messengerMock as any).setup).toHaveBeenCalledTimes(1); }); + + test('should use custom exposureDuration when provided', async () => { + const customDuration = 500; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let intersectionCallback: (entries: any[]) => void; + + // Mock IntersectionObserver + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).IntersectionObserver = jest.fn((cb) => { + intersectionCallback = cb; + return { + observe: jest.fn(), + disconnect: jest.fn(), + }; + }); + + plugin = autocapturePlugin({ + exposureDuration: customDuration, + }); + const loggerProvider: Partial = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider as ILogger, + }; + const amplitude = createMockBrowserClient(); + await amplitude.init('API_KEY', 'USER_ID').promise; + const track = jest.spyOn(amplitude, 'track').mockImplementation(jest.fn()); + + await plugin?.setup?.(config as BrowserConfig, amplitude); + + // Create and expose an element + const element = document.createElement('button'); + element.id = 'exposure-test-button'; + document.body.appendChild(element); + + // Trigger intersection (element becomes visible) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + intersectionCallback!([ + { + isIntersecting: true, + intersectionRatio: 1.0, + target: element, + }, + ]); + + // Should not be exposed before custom duration + jest.advanceTimersByTime(customDuration - 50); + expect(track).not.toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.anything()); + + // Should be exposed after custom duration + jest.advanceTimersByTime(100); + + // Trigger page end to flush the exposure + window.dispatchEvent(new Event('beforeunload')); + + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Element Exposed': expect.arrayContaining(['button#exposure-test-button']), + }), + ); + + // Cleanup + element.remove(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).IntersectionObserver = undefined; + }); }); describe('execute', () => { @@ -221,6 +292,7 @@ describe('autoTrackingPlugin', () => { '[Amplitude] Element Text': 'my-link-text', '[Amplitude] Element Aria Label': 'my-link', '[Amplitude] Element Parent Label': 'my-h2-text', + '[Amplitude] Element Path': 'a#my-link-id', '[Amplitude] Page URL': 'https://www.amplitude.com/unit-test', '[Amplitude] Viewport Height': 768, '[Amplitude] Viewport Width': 1024, @@ -406,6 +478,7 @@ describe('autoTrackingPlugin', () => { '[Amplitude] Element Text': 'my-link-text', '[Amplitude] Element Aria Label': 'my-link', '[Amplitude] Element Parent Label': 'my-h2-text', + '[Amplitude] Element Path': 'a#my-link-id', '[Amplitude] Page URL': 'https://www.amplitude.com/unit-test', '[Amplitude] Viewport Height': 768, '[Amplitude] Viewport Width': 1024, @@ -506,6 +579,7 @@ describe('autoTrackingPlugin', () => { '[Amplitude] Element Text': 'submit', '[Amplitude] Element Aria Label': 'my-button', '[Amplitude] Element Parent Label': 'my-h2-text', + '[Amplitude] Element Path': 'button#my-button-id', '[Amplitude] Page URL': 'https://www.amplitude.com/unit-test', '[Amplitude] Viewport Height': 768, '[Amplitude] Viewport Width': 1024, @@ -577,6 +651,7 @@ describe('autoTrackingPlugin', () => { '[Amplitude] Element Text': 'submit', '[Amplitude] Element Aria Label': 'my-button', '[Amplitude] Element Parent Label': 'my-h2-text', + '[Amplitude] Element Path': 'button#my-button-id', '[Amplitude] Page URL': 'https://www.amplitude.com/unit-test', '[Amplitude] Viewport Height': 768, '[Amplitude] Viewport Width': 1024, @@ -937,6 +1012,7 @@ describe('autoTrackingPlugin', () => { '[Amplitude] Element Tag': 'button', '[Amplitude] Element Text': 'submit', '[Amplitude] Element Parent Label': 'my-h2-text', + '[Amplitude] Element Path': 'button#my-button-id', '[Amplitude] Page URL': 'https://www.amplitude.com/unit-test', '[Amplitude] Viewport Height': 768, '[Amplitude] Viewport Width': 1024, @@ -1381,6 +1457,308 @@ describe('autoTrackingPlugin', () => { expect(track).toHaveBeenCalledTimes(6); }); }); + + describe('page view ID handling', () => { + // Tests for data-extractor.ts lines 160-170 which use optional chaining + // (window?.sessionStorage?.getItem) to safely access sessionStorage + test('should not throw error when sessionStorage is deleted', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + // Save original sessionStorage + const originalSessionStorage = window.sessionStorage; + + // Delete sessionStorage temporarily - tests window?.sessionStorage when sessionStorage is undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).sessionStorage; + + // trigger click event + const link = document.createElement('a'); + link.setAttribute('id', 'test-link-id'); + link.setAttribute('class', 'test-link-class'); + link.href = 'https://www.amplitude.com/test'; + link.text = 'test-link-text'; + document.body.appendChild(link); + + document.getElementById('test-link-id')?.dispatchEvent(new Event('click')); + jest.advanceTimersByTime(TESTING_DEBOUNCE_TIME + 3); + + expect(track).toHaveBeenCalledTimes(1); + // Verify that page view ID is not included in the event properties + expect(track).toHaveBeenNthCalledWith( + 1, + '[Amplitude] Element Clicked', + expect.not.objectContaining({ + '[Amplitude] Page View ID': expect.anything(), + }), + ); + + // Restore sessionStorage + window.sessionStorage = originalSessionStorage; + + // Cleanup + document.getElementById('test-link-id')?.remove(); + }); + + test('should not throw error when window.sessionStorage is undefined', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + // Save original window.sessionStorage + const originalSessionStorage = window.sessionStorage; + + // Set window.sessionStorage to undefined - tests optional chaining when window?.sessionStorage is undefined + Object.defineProperty(window, 'sessionStorage', { + value: undefined, + writable: true, + configurable: true, + }); + + // trigger click event + const link = document.createElement('a'); + link.setAttribute('id', 'test-link-id-2'); + link.setAttribute('class', 'test-link-class'); + link.href = 'https://www.amplitude.com/test'; + link.text = 'test-link-text'; + document.body.appendChild(link); + + document.getElementById('test-link-id-2')?.dispatchEvent(new Event('click')); + jest.advanceTimersByTime(TESTING_DEBOUNCE_TIME + 3); + + expect(track).toHaveBeenCalledTimes(1); + // Verify that page view ID is not included in the event properties + expect(track).toHaveBeenNthCalledWith( + 1, + '[Amplitude] Element Clicked', + expect.not.objectContaining({ + '[Amplitude] Page View ID': expect.anything(), + }), + ); + + // Restore sessionStorage + window.sessionStorage = originalSessionStorage; + + // Cleanup + document.getElementById('test-link-id-2')?.remove(); + }); + }); + }); + + describe('Viewport Content Updated Tracking', () => { + const API_KEY = 'API_KEY'; + const USER_ID = 'USER_ID'; + + let instance = createMockBrowserClient(); + let track: jest.SpyInstance; + let loggerProvider: ILogger; + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalPushState = history.pushState; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let intersectionCallback: (entries: any[]) => void; + + beforeEach(async () => { + // Ensure navigation API is not present to test fallback (pushState proxy) + Object.defineProperty(window, 'navigation', { + value: undefined, + writable: true, + configurable: true, + }); + + loggerProvider = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + } as unknown as ILogger; + + // Mock IntersectionObserver + (window as any).IntersectionObserver = jest.fn((cb) => { + intersectionCallback = cb; + return { + observe: jest.fn(), + disconnect: jest.fn(), + }; + }); + + instance = createMockBrowserClient(); + await instance.init(API_KEY, USER_ID).promise; + track = jest.spyOn(instance, 'track').mockImplementation(jest.fn()); + }); + + afterEach(async () => { + if (originalPushState) { + history.pushState = originalPushState; + } + (window as any).IntersectionObserver = undefined; + }); + + test('should track [Amplitude] Viewport Content Updated on beforeunload', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + window.dispatchEvent(new Event('beforeunload')); + + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Page URL': expect.any(String), + '[Amplitude] Viewport Height': expect.any(Number), + '[Amplitude] Viewport Width': expect.any(Number), + }), + ); + }); + + test('should not track duplicate [Amplitude] Viewport Content Updated events on multiple beforeunload', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + window.dispatchEvent(new Event('beforeunload')); + window.dispatchEvent(new Event('beforeunload')); + + expect(track).toHaveBeenCalledTimes(1); + }); + + test('should track [Amplitude] Viewport Content Updated on history.pushState and reset state', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + // history.pushState is proxied. + history.pushState({}, 'test', '/new-page'); + + expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object)); + + jest.advanceTimersByTime(1000); + // change scroll depth to trigger a new viewport content updated event + Object.defineProperty(window, 'scrollY', { value: 100, writable: true }); + Object.defineProperty(window, 'pageYOffset', { value: 100, writable: true }); + window.dispatchEvent(new Event('scroll')); + + // Verify it can fire again (pageViewEndFired should be reset to false by the proxy) + history.pushState({}, 'test', '/another-page'); + expect(track).toHaveBeenCalledTimes(2); + }); + + test('should track [Amplitude] Viewport Content Updated on popstate event', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + // Simulate popstate event + window.dispatchEvent(new Event('popstate')); + + expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object)); + }); + + test('should flush Viewport Content Updated event when exposure buffer limit is reached', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + // Mock finder to avoid DOM dependencies for path generation + // We can't easily mock the finder inside the module from here without jest.mock hoisting. + // Instead, we rely on elements having IDs which finder uses. + + // We need to trigger exposure for enough unique elements to exceed 18000 chars. + // exposedString = JSON.stringify(["#id1", "#id2", ...]) + // A simple id like "#e-1" is 4 chars. Quotes and comma add 3 chars. ~7 chars per element. + // 18000 / 7 = ~2571 elements. + + const entries: any[] = []; + const elements: HTMLElement[] = []; + + // Create a batch of elements + // Make ids long to reduce iteration count + const longIdPrefix = 'e'.repeat(100); // 100 chars + const count = 180; // 180 * 100 = 18000. + + for (let i = 0; i < count; i++) { + const el = document.createElement('div'); + el.id = `${longIdPrefix}-${i}`; + document.body.appendChild(el); + elements.push(el); + + entries.push({ + isIntersecting: true, + intersectionRatio: 1.0, + target: el, + }); + } + + // Trigger intersection + intersectionCallback(entries); + + // Exposure has a debounce or delay? + // trackExposure uses observables which might be async or use internal logic. + // trackExposure has an internal buffer/timer. + // trackExposure implementation waits for element to be visible for some time (defaults 0?) + // Actually trackExposure defaults to 0 debounce/timeout? + // Let's check track-exposure.ts logic. It sets a timeout. + // We need to advance timers. + + // The default threshold is likely 0 if not configured? + // No, trackExposure has hardcoded logic or uses options. + // Looking at track-exposure.ts: + // It sets a timeout of 2000ms (hardcoded in the file I saw previously?) + // Or it reads options? + + // Let's verify with a smaller test first or assume 2s. + jest.advanceTimersByTime(2000); // Wait for exposure to be confirmed + + // Now onExposure should be called for each element. + // onExposure adds to set and checks size. + + // We expect at least one track call + expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object)); + + // Cleanup + elements.forEach((el) => el.remove()); + }); + + test('should include scroll and exposure state in Viewport Content Updated event', async () => { + const config: Partial = { + defaultTracking: false, + loggerProvider: loggerProvider, + }; + await plugin?.setup?.(config as BrowserConfig, instance); + + // Trigger a scroll to update state + Object.defineProperty(window, 'scrollX', { value: 100, writable: true }); + Object.defineProperty(window, 'scrollY', { value: 200, writable: true }); + Object.defineProperty(window, 'pageXOffset', { value: 100, writable: true }); + Object.defineProperty(window, 'pageYOffset', { value: 200, writable: true }); + window.dispatchEvent(new Event('scroll')); + + // Trigger page view end + window.dispatchEvent(new Event('beforeunload')); + + expect(track).toHaveBeenCalledWith( + '[Amplitude] Viewport Content Updated', + expect.objectContaining({ + '[Amplitude] Max Page X': 100 + 1024, + '[Amplitude] Max Page Y': 200 + 768, + '[Amplitude] Element Exposed': expect.any(Array), + }), + ); + }); }); describe('teardown', () => { diff --git a/packages/plugin-autocapture-browser/test/observable.test.ts b/packages/plugin-autocapture-browser/test/observable.test.ts new file mode 100644 index 000000000..b744704b8 --- /dev/null +++ b/packages/plugin-autocapture-browser/test/observable.test.ts @@ -0,0 +1,147 @@ +import { createExposureObservable } from '../src/observables'; +import { Observable } from '@amplitude/analytics-core'; +import { TimestampedEvent } from '../src/helpers'; + +describe('createExposureObservable', () => { + let mutationObservable: Observable>; + let mockMutationObserver: { subscribe: jest.Mock }; + let mockIntersectionObserver: { observe: jest.Mock; disconnect: jest.Mock }; + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; + let observers: any[] = []; + + beforeEach(() => { + observers = []; + // Mock Mutation Observable + mockMutationObserver = { + subscribe: jest.fn((cb) => { + observers.push(cb); + return { unsubscribe: jest.fn() }; + }), + }; + mutationObservable = mockMutationObserver as unknown as Observable>; + + // Mock IntersectionObserver + mockIntersectionObserver = { + observe: jest.fn(), + disconnect: jest.fn(), + }; + + (global as any).IntersectionObserver = jest.fn((cb) => { + intersectionCallback = cb; + return mockIntersectionObserver; + }); + + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.clearAllMocks(); + }); + + test('should observe initial elements matching the allowlist', () => { + const div = document.createElement('div'); + div.className = 'track-me'; + document.body.appendChild(div); + + const exposureObservable = createExposureObservable(mutationObservable, ['.track-me']); + exposureObservable.subscribe(() => { + return; + }); + + expect(mockIntersectionObserver.observe).toHaveBeenCalledWith(div); + }); + + test('should emit event when element intersects (visible)', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const listener = jest.fn(); + + const exposureObservable = createExposureObservable(mutationObservable, ['div']); + exposureObservable.subscribe(listener); + + // Simulate intersection + const entry = { + isIntersecting: true, + intersectionRatio: 1.0, + target: div, + } as unknown as IntersectionObserverEntry; + + intersectionCallback([entry]); + + expect(listener).toHaveBeenCalledWith(entry); + }); + + test('should emit event when element leaves viewport (invisible)', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const listener = jest.fn(); + + const exposureObservable = createExposureObservable(mutationObservable, ['div']); + exposureObservable.subscribe(listener); + + // Simulate leaving viewport + const entry = { + isIntersecting: false, + intersectionRatio: 0.5, + target: div, + } as unknown as IntersectionObserverEntry; + + intersectionCallback([entry]); + + expect(listener).toHaveBeenCalledWith(entry); + }); + + test('should observe new elements added via mutation', () => { + const exposureObservable = createExposureObservable(mutationObservable, ['div']); + exposureObservable.subscribe(() => { + return; + }); + + // Simulate mutation adding a node + const newDiv = document.createElement('div'); + const mutationRecord = { + addedNodes: [newDiv] as unknown as NodeList, + } as MutationRecord; + + // Trigger mutation subscription callback + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + observers.forEach((cb) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return cb({ + event: [mutationRecord], + timestamp: Date.now(), + }); + }); + + expect(mockIntersectionObserver.observe).toHaveBeenCalledWith(newDiv); + }); + + test('should disconnect observer on unsubscribe', () => { + const exposureObservable = createExposureObservable(mutationObservable, ['div']); + const subscription = exposureObservable.subscribe(() => { + return; + }); + + subscription.unsubscribe(); + + expect(mockIntersectionObserver.disconnect).toHaveBeenCalled(); + }); + + test('should handle missing IntersectionObserver support gracefully', () => { + const originalIntersectionObserver = (global as any).IntersectionObserver; + (global as any).IntersectionObserver = undefined; + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + const exposureObservable = createExposureObservable(mutationObservable, ['div']); + const subscription = exposureObservable.subscribe(() => { + return; + }); + + expect(consoleSpy).toHaveBeenCalledWith('IntersectionObserver not supported'); + + subscription.unsubscribe(); + consoleSpy.mockRestore(); + (global as any).IntersectionObserver = originalIntersectionObserver; + }); +}); diff --git a/packages/plugin-autocapture-browser/test/observables-coverage.test.ts b/packages/plugin-autocapture-browser/test/observables-coverage.test.ts new file mode 100644 index 000000000..edc80aa34 --- /dev/null +++ b/packages/plugin-autocapture-browser/test/observables-coverage.test.ts @@ -0,0 +1,90 @@ +import { Observable, getGlobalScope } from '@amplitude/analytics-core'; +import { TimestampedEvent } from '../src/helpers'; + +jest.mock('@amplitude/analytics-core', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@amplitude/analytics-core'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getGlobalScope: jest.fn(), + }; +}); + +const mockGetGlobalScope = getGlobalScope as jest.Mock; + +import { + createClickObservable, + createScrollObservable, + createMutationObservable, + createExposureObservable, +} from '../src/observables'; + +describe('Observables Coverage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when getGlobalScope returns undefined', () => { + beforeEach(() => { + mockGetGlobalScope.mockReturnValue(undefined); + }); + + test('createClickObservable should handle undefined global scope safely', () => { + const observable = createClickObservable(); + const subscription = observable.subscribe(() => { + return; + }); + subscription.unsubscribe(); + // Should not throw + expect(mockGetGlobalScope).toHaveBeenCalled(); + }); + + test('createScrollObservable should handle undefined global scope safely', () => { + const observable = createScrollObservable(); + const subscription = observable.subscribe(() => { + return; + }); + subscription.unsubscribe(); + // Should not throw + expect(mockGetGlobalScope).toHaveBeenCalled(); + }); + + test('createExposureObservable should handle undefined global scope safely', () => { + const mutationObservable = new Observable>(() => { + return; + }); + const observable = createExposureObservable(mutationObservable, ['div']); + const subscription = observable.subscribe(() => { + return; + }); + subscription.unsubscribe(); + // Should not throw + expect(mockGetGlobalScope).toHaveBeenCalled(); + }); + }); + + describe('createMutationObservable', () => { + test('should handle missing document.body safely', () => { + // Save original body + const originalBody = document.body; + // Delete body + Object.defineProperty(document, 'body', { value: null, configurable: true }); + + mockGetGlobalScope.mockReturnValue(window); // Ensure global scope is present + + const observable = createMutationObservable(); + const subscription = observable.subscribe(() => { + return; + }); + + subscription.unsubscribe(); + + // Restore body + Object.defineProperty(document, 'body', { value: originalBody, configurable: true }); + + // Verify it didn't throw and executed safely + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/plugin-experiment-browser/CHANGELOG.md b/packages/plugin-experiment-browser/CHANGELOG.md index cbba2fd97..e7b0d679c 100644 --- a/packages/plugin-experiment-browser/CHANGELOG.md +++ b/packages/plugin-experiment-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [1.0.0-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-experiment-browser@1.0.0-beta.4...@amplitude/plugin-experiment-browser@1.0.0-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-experiment-browser + + + + + # [1.0.0-beta.4](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-experiment-browser@1.0.0-beta.3...@amplitude/plugin-experiment-browser@1.0.0-beta.4) (2026-01-26) diff --git a/packages/plugin-experiment-browser/package.json b/packages/plugin-experiment-browser/package.json index bd0670ce9..59867a214 100644 --- a/packages/plugin-experiment-browser/package.json +++ b/packages/plugin-experiment-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-experiment-browser", - "version": "1.0.0-beta.4", + "version": "1.0.0-feat-zoning-alpha.0", "description": "", "repository": { "type": "git", diff --git a/packages/plugin-experiment-browser/src/version.ts b/packages/plugin-experiment-browser/src/version.ts index 3206687fa..0b234ac5a 100644 --- a/packages/plugin-experiment-browser/src/version.ts +++ b/packages/plugin-experiment-browser/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by `npm run version-file`. DO NOT EDIT -export const VERSION = '1.0.0-beta.4'; +export const VERSION = '1.0.0-feat-zoning-alpha.0'; diff --git a/packages/plugin-global-user-properties/CHANGELOG.md b/packages/plugin-global-user-properties/CHANGELOG.md index 7bb3949d7..a8b25d63d 100644 --- a/packages/plugin-global-user-properties/CHANGELOG.md +++ b/packages/plugin-global-user-properties/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.2.116-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-global-user-properties@1.2.115...@amplitude/plugin-global-user-properties@1.2.116-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-global-user-properties + + + + + ## [1.2.115](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-global-user-properties@1.2.114...@amplitude/plugin-global-user-properties@1.2.115) (2026-01-26) **Note:** Version bump only for package @amplitude/plugin-global-user-properties diff --git a/packages/plugin-global-user-properties/package.json b/packages/plugin-global-user-properties/package.json index ba4a8c85f..7a7c6d7e6 100644 --- a/packages/plugin-global-user-properties/package.json +++ b/packages/plugin-global-user-properties/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-global-user-properties", - "version": "1.2.115", + "version": "1.2.116-feat-zoning-alpha.0", "description": "An event enrichment plugin that adds the experimental global user properties field to events", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-network-capture-browser/CHANGELOG.md b/packages/plugin-network-capture-browser/CHANGELOG.md index 840128a24..a188f07e5 100644 --- a/packages/plugin-network-capture-browser/CHANGELOG.md +++ b/packages/plugin-network-capture-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.7.9-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-network-capture-browser@1.7.8...@amplitude/plugin-network-capture-browser@1.7.9-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-network-capture-browser + + + + + ## [1.7.8](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-network-capture-browser@1.7.7...@amplitude/plugin-network-capture-browser@1.7.8) (2026-01-26) **Note:** Version bump only for package @amplitude/plugin-network-capture-browser diff --git a/packages/plugin-network-capture-browser/package.json b/packages/plugin-network-capture-browser/package.json index 6dd1ec772..c6165f282 100644 --- a/packages/plugin-network-capture-browser/package.json +++ b/packages/plugin-network-capture-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-network-capture-browser", - "version": "1.7.8", + "version": "1.7.9-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-network-capture-browser/src/version.ts b/packages/plugin-network-capture-browser/src/version.ts index 3a6e2c07c..c0f39a605 100644 --- a/packages/plugin-network-capture-browser/src/version.ts +++ b/packages/plugin-network-capture-browser/src/version.ts @@ -1 +1 @@ -export const VERSION = '1.7.8'; +export const VERSION = '1.7.9-feat-zoning-alpha.0'; diff --git a/packages/plugin-page-url-enrichment-browser/CHANGELOG.md b/packages/plugin-page-url-enrichment-browser/CHANGELOG.md index fa4e45a28..810a28797 100644 --- a/packages/plugin-page-url-enrichment-browser/CHANGELOG.md +++ b/packages/plugin-page-url-enrichment-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.5.15-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-page-url-enrichment-browser@0.5.14...@amplitude/plugin-page-url-enrichment-browser@0.5.15-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-page-url-enrichment-browser + + + + + ## [0.5.14](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-page-url-enrichment-browser@0.5.13...@amplitude/plugin-page-url-enrichment-browser@0.5.14) (2026-01-26) **Note:** Version bump only for package @amplitude/plugin-page-url-enrichment-browser diff --git a/packages/plugin-page-url-enrichment-browser/package.json b/packages/plugin-page-url-enrichment-browser/package.json index 2857f4989..90efe40da 100644 --- a/packages/plugin-page-url-enrichment-browser/package.json +++ b/packages/plugin-page-url-enrichment-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-page-url-enrichment-browser", - "version": "0.5.14", + "version": "0.5.15-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-page-view-tracking-browser/CHANGELOG.md b/packages/plugin-page-view-tracking-browser/CHANGELOG.md index 742ef941a..3590ae691 100644 --- a/packages/plugin-page-view-tracking-browser/CHANGELOG.md +++ b/packages/plugin-page-view-tracking-browser/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [2.7.0-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-page-view-tracking-browser@2.6.11...@amplitude/plugin-page-view-tracking-browser@2.7.0-feat-zoning-alpha.0) (2026-02-03) + + +### Bug Fixes + +* import in test ([720cd5e](https://github.com/amplitude/Amplitude-TypeScript/commit/720cd5e4e4d6b2d1d074c50047f1456218be62d2)) + + +### Features + +* **page-view-tracking-browser:** Track page view id in session storage ([#1379](https://github.com/amplitude/Amplitude-TypeScript/issues/1379)) ([51fd3d2](https://github.com/amplitude/Amplitude-TypeScript/commit/51fd3d2ce3d22c312d12079dd8c036a5520df0f1)) + + + + + ## [2.6.11](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-page-view-tracking-browser@2.6.10...@amplitude/plugin-page-view-tracking-browser@2.6.11) (2026-01-26) **Note:** Version bump only for package @amplitude/plugin-page-view-tracking-browser diff --git a/packages/plugin-page-view-tracking-browser/package.json b/packages/plugin-page-view-tracking-browser/package.json index e3156d8c6..d26c0d024 100644 --- a/packages/plugin-page-view-tracking-browser/package.json +++ b/packages/plugin-page-view-tracking-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-page-view-tracking-browser", - "version": "2.6.11", + "version": "2.7.0-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts b/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts index 470d222fc..1173810e4 100644 --- a/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts +++ b/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts @@ -11,11 +11,18 @@ import { CampaignParser, getGlobalScope, BASE_CAMPAIGN, + BrowserStorage, + UUID, } from '@amplitude/analytics-core'; import { CreatePageViewTrackingPlugin, Options } from './typings/page-view-tracking'; import { omitUndefined } from './utils'; export const defaultPageViewEvent = '[Amplitude] Page Viewed'; +export const PAGE_VIEW_SESSION_STORAGE_KEY = 'AMP_PAGE_VIEW'; + +type PageViewSessionStorage = { + pageViewId: string; +}; export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Options = {}) => { let amplitude: BrowserClient | undefined; @@ -23,6 +30,7 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op let loggerProvider: ILogger | undefined = undefined; let isTracking = false; let localConfig: BrowserConfig; + let sessionStorage: BrowserStorage | undefined; const { trackOn, trackHistoryChanges, eventType = defaultPageViewEvent } = options; const getDecodeURI = (locationStr: string): string => { @@ -37,7 +45,7 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op return decodedLocationStr; }; - const createPageViewEvent = async (): Promise => { + const createPageViewEvent = async (pageViewId: string | undefined): Promise => { /* istanbul ignore next */ const locationHREF = getDecodeURI((typeof location !== 'undefined' && location.href) || ''); return { @@ -51,6 +59,7 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op /* istanbul ignore next */ (typeof location !== 'undefined' && getDecodeURI(location.pathname)) || '', '[Amplitude] Page Title': /* istanbul ignore next */ getPageTitle(replaceSensitiveString), '[Amplitude] Page URL': locationHREF.split('?')[0], + '[Amplitude] Page View ID': pageViewId, }, }; }; @@ -71,14 +80,21 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op previousURL = newURL; if (shouldTrackPageView) { + // Generate new page view id and set it in session storage + let pageViewId: string | undefined; + if (sessionStorage) { + pageViewId = UUID(); + void sessionStorage.set(PAGE_VIEW_SESSION_STORAGE_KEY, { pageViewId }); + } + /* istanbul ignore next */ loggerProvider?.log('Tracking page view event'); - amplitude?.track(await createPageViewEvent()); + amplitude?.track(await createPageViewEvent(pageViewId)); } }; /* istanbul ignore next */ - const trackHistoryPageViewWrapper = () => { + const handlePageChange = () => { void trackHistoryPageView(); }; @@ -94,9 +110,11 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op loggerProvider.log('Installing @amplitude/plugin-page-view-tracking-browser'); isTracking = true; - if (globalScope) { - globalScope.addEventListener('popstate', trackHistoryPageViewWrapper); + // init session storage + sessionStorage = new BrowserStorage(globalScope.sessionStorage); + + globalScope.addEventListener('popstate', handlePageChange); /* istanbul ignore next */ // There is no global browser listener for changes to history, so we have @@ -107,7 +125,7 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op apply: (target, thisArg, [state, unused, url]) => { target.apply(thisArg, [state, unused, url]); if (isTracking) { - void trackHistoryPageView(); + handlePageChange(); } }, }); @@ -115,8 +133,15 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op if (shouldTrackOnPageLoad()) { loggerProvider.log('Tracking page view event'); + // Generate new page view id and set it in session storage + let pageViewId: string | undefined; + + if (sessionStorage) { + pageViewId = UUID(); + void sessionStorage.set(PAGE_VIEW_SESSION_STORAGE_KEY, { pageViewId }); + } - amplitude.track(await createPageViewEvent()); + amplitude.track(await createPageViewEvent(pageViewId)); } }, @@ -124,7 +149,14 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op if (trackOn === 'attribution' && isCampaignEvent(event)) { /* istanbul ignore next */ // loggerProvider should be defined by the time execute is invoked loggerProvider?.log('Enriching campaign event to page view event with campaign parameters'); - const pageViewEvent = await createPageViewEvent(); + // Retrieve current page view id from session storage + let pageViewId: string | undefined; + if (sessionStorage) { + const pageViewSession = await sessionStorage.get(PAGE_VIEW_SESSION_STORAGE_KEY); + pageViewId = pageViewSession?.pageViewId; + } + + const pageViewEvent = await createPageViewEvent(pageViewId); event.event_type = pageViewEvent.event_type; event.event_properties = { ...event.event_properties, @@ -145,7 +177,7 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op teardown: async () => { if (globalScope) { - globalScope.removeEventListener('popstate', trackHistoryPageViewWrapper); + globalScope.removeEventListener('popstate', handlePageChange); isTracking = false; } }, diff --git a/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts b/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts index ae55aeb41..cff296fa1 100644 --- a/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts +++ b/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts @@ -1,6 +1,31 @@ -import { Logger, UUID, BrowserClient, BrowserConfig, LogLevel } from '@amplitude/analytics-core'; -import { defaultPageViewEvent, pageViewTrackingPlugin, shouldTrackHistoryPageView } from '../src/page-view-tracking'; -import { CookieStorage, FetchTransport } from '@amplitude/analytics-core'; +import { + Logger, + UUID, + BrowserClient, + BrowserConfig, + LogLevel, + getGlobalScope, + CookieStorage, + FetchTransport, +} from '@amplitude/analytics-core'; +import { + defaultPageViewEvent, + pageViewTrackingPlugin, + shouldTrackHistoryPageView, + PAGE_VIEW_SESSION_STORAGE_KEY, +} from '../src/page-view-tracking'; + +jest.mock('@amplitude/analytics-core', () => { + const actual = jest.requireActual('@amplitude/analytics-core'); + return { + ...actual, + UUID: jest.fn(function (...args) { + // Call through to original + return actual.UUID(...args); + }), + getGlobalScope: jest.fn(() => (typeof window !== 'undefined' ? window : undefined)), + }; +}); // Mock BrowserClient implementation const createMockBrowserClient = (): jest.Mocked => { @@ -127,6 +152,12 @@ describe('pageViewTrackingPlugin', () => { // Block event loop for 1s before asserting await new Promise((resolve) => setTimeout(resolve, 1000)); + // Expect session storage to match the latest page view id + const sessionStorageItem = window.sessionStorage.getItem(PAGE_VIEW_SESSION_STORAGE_KEY); + expect(sessionStorageItem).toBeDefined(); + const sessionStorageItemJson = JSON.parse(sessionStorageItem as string) as { pageViewId: string }; + const pageViewIdSessionStorage = sessionStorageItemJson.pageViewId; + expect(track).toHaveBeenNthCalledWith(2, { event_properties: { '[Amplitude] Page Domain': newURL.hostname, @@ -134,6 +165,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': newURL.pathname, '[Amplitude] Page Title': '', '[Amplitude] Page URL': newURL.toString(), + '[Amplitude] Page View ID': pageViewIdSessionStorage, }, event_type: '[Amplitude] Page Viewed', }); @@ -176,6 +208,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': pathname, '[Amplitude] Page Title': '', '[Amplitude] Page URL': `https://${hostname}${pathname}`, + '[Amplitude] Page View ID': expect.any(String), utm_source: 'google', utm_medium: 'cpc', utm_campaign: 'brand', @@ -273,6 +306,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': oldURL.pathname, '[Amplitude] Page Title': '', '[Amplitude] Page URL': oldURL.toString(), + '[Amplitude] Page View ID': expect.any(String), }, event_type: '[Amplitude] Page Viewed', }); @@ -284,6 +318,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': '/home-шеллы', '[Amplitude] Page Title': '', '[Amplitude] Page URL': 'https://www.example.com/home-шеллы', + '[Amplitude] Page View ID': expect.any(String), }, event_type: '[Amplitude] Page Viewed', }); @@ -328,6 +363,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': oldURL.pathname, '[Amplitude] Page Title': '', '[Amplitude] Page URL': oldURL.toString(), + '[Amplitude] Page View ID': expect.any(String), }, event_type: '[Amplitude] Page Viewed', }); @@ -339,6 +375,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': malformedPath, '[Amplitude] Page Title': '', '[Amplitude] Page URL': malformedURL, + '[Amplitude] Page View ID': expect.any(String), }, event_type: '[Amplitude] Page Viewed', }); @@ -381,6 +418,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': oldURL.pathname, '[Amplitude] Page Title': '', '[Amplitude] Page URL': oldURL.toString(), + '[Amplitude] Page View ID': expect.any(String), }, event_type: '[Amplitude] Page Viewed', }); @@ -392,6 +430,7 @@ describe('pageViewTrackingPlugin', () => { '[Amplitude] Page Path': newURL.pathname, '[Amplitude] Page Title': '', '[Amplitude] Page URL': newBaseURL, + '[Amplitude] Page View ID': expect.any(String), }, event_type: '[Amplitude] Page Viewed', }); @@ -459,6 +498,138 @@ describe('pageViewTrackingPlugin', () => { expect(event?.event_type).toBe('[Amplitude] Page Viewed'); }); + test('should track attribution page view when session storage is unavailable', async () => { + // Mock getGlobalScope to return undefined to simulate no global scope + const orig = (getGlobalScope as jest.Mock).getMockImplementation(); + + (getGlobalScope as jest.Mock).mockReturnValue(undefined); + + const amplitude = createMockBrowserClient(); + + const plugin = pageViewTrackingPlugin({ + trackOn: 'attribution', + }); + + await plugin.setup?.(mockConfig, amplitude); + const event = await plugin.execute?.({ + event_type: '$identify', + user_properties: { + $set: { + utm_source: 'amp-test', + }, + $setOnce: { + initial_dclid: 'EMPTY', + initial_fbclid: 'EMPTY', + initial_gbraid: 'EMPTY', + initial_gclid: 'EMPTY', + initial_ko_click_id: 'EMPTY', + initial_li_fat_id: 'EMPTY', + initial_msclkid: 'EMPTY', + initial_wbraid: 'EMPTY', + initial_referrer: 'EMPTY', + initial_referring_domain: 'EMPTY', + initial_rdt_cid: 'EMPTY', + initial_ttclid: 'EMPTY', + initial_twclid: 'EMPTY', + initial_utm_campaign: 'EMPTY', + initial_utm_content: 'EMPTY', + initial_utm_id: 'EMPTY', + initial_utm_medium: 'EMPTY', + initial_utm_source: 'amp-test', + initial_utm_term: 'EMPTY', + }, + $unset: { + dclid: '-', + fbclid: '-', + gbraid: '-', + gclid: '-', + ko_click_id: '-', + li_fat_id: '-', + msclkid: '-', + wbraid: '-', + referrer: '-', + referring_domain: '-', + rdt_cid: '-', + ttclid: '-', + twclid: '-', + utm_campaign: '-', + utm_content: '-', + utm_id: '-', + utm_medium: '-', + utm_term: '-', + }, + }, + }); + + expect(event?.event_type).toBe('[Amplitude] Page Viewed'); + expect(event?.event_properties).not.toHaveProperty('[Amplitude] Page View ID'); + (getGlobalScope as jest.Mock).mockImplementation(orig); + }); + + test('should track attribution page view when session storage is empty', async () => { + const amplitude = createMockBrowserClient(); + const plugin = pageViewTrackingPlugin({ + trackOn: 'attribution', + }); + + // Clear session storage + window.sessionStorage.setItem(PAGE_VIEW_SESSION_STORAGE_KEY, 'undefined'); + + await plugin.setup?.(mockConfig, amplitude); + const event = await plugin.execute?.({ + event_type: '$identify', + user_properties: { + $set: { + utm_source: 'amp-test', + }, + $setOnce: { + initial_dclid: 'EMPTY', + initial_fbclid: 'EMPTY', + initial_gbraid: 'EMPTY', + initial_gclid: 'EMPTY', + initial_ko_click_id: 'EMPTY', + initial_li_fat_id: 'EMPTY', + initial_msclkid: 'EMPTY', + initial_wbraid: 'EMPTY', + initial_referrer: 'EMPTY', + initial_referring_domain: 'EMPTY', + initial_rdt_cid: 'EMPTY', + initial_ttclid: 'EMPTY', + initial_twclid: 'EMPTY', + initial_utm_campaign: 'EMPTY', + initial_utm_content: 'EMPTY', + initial_utm_id: 'EMPTY', + initial_utm_medium: 'EMPTY', + initial_utm_source: 'amp-test', + initial_utm_term: 'EMPTY', + }, + $unset: { + dclid: '-', + fbclid: '-', + gbraid: '-', + gclid: '-', + ko_click_id: '-', + li_fat_id: '-', + msclkid: '-', + wbraid: '-', + referrer: '-', + referring_domain: '-', + rdt_cid: '-', + ttclid: '-', + twclid: '-', + utm_campaign: '-', + utm_content: '-', + utm_id: '-', + utm_medium: '-', + utm_term: '-', + }, + }, + }); + + expect(event?.event_type).toBe('[Amplitude] Page Viewed'); + expect(event?.event_properties).not.toHaveProperty('[Amplitude] Page View ID'); + }); + test('should return same event if it is not attribution event', async () => { const plugin = pageViewTrackingPlugin({ trackOn: 'attribution', diff --git a/packages/plugin-session-replay-browser/CHANGELOG.md b/packages/plugin-session-replay-browser/CHANGELOG.md index c2c651ebd..7e33a55f6 100644 --- a/packages/plugin-session-replay-browser/CHANGELOG.md +++ b/packages/plugin-session-replay-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.25.11-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-session-replay-browser@1.25.10...@amplitude/plugin-session-replay-browser@1.25.11-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-session-replay-browser + + + + + ## [1.25.10](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-session-replay-browser@1.25.9...@amplitude/plugin-session-replay-browser@1.25.10) (2026-01-26) **Note:** Version bump only for package @amplitude/plugin-session-replay-browser diff --git a/packages/plugin-session-replay-browser/package.json b/packages/plugin-session-replay-browser/package.json index ca300e622..eb691b363 100644 --- a/packages/plugin-session-replay-browser/package.json +++ b/packages/plugin-session-replay-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-session-replay-browser", - "version": "1.25.10", + "version": "1.25.11-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-session-replay-browser/src/version.ts b/packages/plugin-session-replay-browser/src/version.ts index c61f7f1a0..b5831f5a2 100644 --- a/packages/plugin-session-replay-browser/src/version.ts +++ b/packages/plugin-session-replay-browser/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by `pnpm version-file`. DO NOT EDIT -export const VERSION = '1.25.10'; +export const VERSION = '1.25.11-feat-zoning-alpha.0'; diff --git a/packages/plugin-session-replay-react-native/CHANGELOG.md b/packages/plugin-session-replay-react-native/CHANGELOG.md index fd4b5b2be..8a9c41f31 100644 --- a/packages/plugin-session-replay-react-native/CHANGELOG.md +++ b/packages/plugin-session-replay-react-native/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.4.7-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-session-replay-react-native@0.4.6...@amplitude/plugin-session-replay-react-native@0.4.7-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-session-replay-react-native + + + + + ## [0.4.6](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-session-replay-react-native@0.4.5...@amplitude/plugin-session-replay-react-native@0.4.6) (2026-01-14) **Note:** Version bump only for package @amplitude/plugin-session-replay-react-native diff --git a/packages/plugin-session-replay-react-native/package.json b/packages/plugin-session-replay-react-native/package.json index 2776ceb3a..511da44bd 100644 --- a/packages/plugin-session-replay-react-native/package.json +++ b/packages/plugin-session-replay-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-session-replay-react-native", - "version": "0.4.6", + "version": "0.4.7-feat-zoning-alpha.0", "description": "Amplitude Session Replay plugin for React Native", "keywords": [ "analytics", diff --git a/packages/plugin-session-replay-react-native/src/version.ts b/packages/plugin-session-replay-react-native/src/version.ts index 9bf7ce588..dcfb9c699 100644 --- a/packages/plugin-session-replay-react-native/src/version.ts +++ b/packages/plugin-session-replay-react-native/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.4.6'; +export const VERSION = '0.4.7-feat-zoning-alpha.0'; diff --git a/packages/plugin-web-attribution-browser/CHANGELOG.md b/packages/plugin-web-attribution-browser/CHANGELOG.md index 5b96f7930..3c6b1fb9c 100644 --- a/packages/plugin-web-attribution-browser/CHANGELOG.md +++ b/packages/plugin-web-attribution-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.1.109-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-web-attribution-browser@2.1.108...@amplitude/plugin-web-attribution-browser@2.1.109-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-web-attribution-browser + + + + + ## [2.1.108](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-web-attribution-browser@2.1.107...@amplitude/plugin-web-attribution-browser@2.1.108) (2026-01-26) **Note:** Version bump only for package @amplitude/plugin-web-attribution-browser diff --git a/packages/plugin-web-attribution-browser/package.json b/packages/plugin-web-attribution-browser/package.json index cfdb4e616..5b43b8e1e 100644 --- a/packages/plugin-web-attribution-browser/package.json +++ b/packages/plugin-web-attribution-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-web-attribution-browser", - "version": "2.1.108", + "version": "2.1.109-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-web-attribution-browser/test/web-attribution.test.ts b/packages/plugin-web-attribution-browser/test/web-attribution.test.ts index bb889f378..2f900d61a 100644 --- a/packages/plugin-web-attribution-browser/test/web-attribution.test.ts +++ b/packages/plugin-web-attribution-browser/test/web-attribution.test.ts @@ -2,8 +2,7 @@ import { createInstance } from '@amplitude/analytics-browser'; import { BASE_CAMPAIGN, CampaignParser, CookieStorage, FetchTransport } from '@amplitude/analytics-client-common'; import { webAttributionPlugin } from '../src/web-attribution'; import * as helpers from '@amplitude/analytics-client-common'; -import { BrowserConfig, LogLevel } from '@amplitude/analytics-core'; -import { Logger, UUID } from '@amplitude/analytics-core'; +import { BrowserConfig, LogLevel, Logger, UUID } from '@amplitude/analytics-core'; describe('webAttributionPlugin', () => { const mockConfig: BrowserConfig = { diff --git a/packages/plugin-web-vitals-browser/CHANGELOG.md b/packages/plugin-web-vitals-browser/CHANGELOG.md index 5c4ca1b6a..340a4dbc5 100644 --- a/packages/plugin-web-vitals-browser/CHANGELOG.md +++ b/packages/plugin-web-vitals-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.1.10-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-web-vitals-browser@1.1.9...@amplitude/plugin-web-vitals-browser@1.1.10-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/plugin-web-vitals-browser + + + + + ## [1.1.9](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/plugin-web-vitals-browser@1.1.8...@amplitude/plugin-web-vitals-browser@1.1.9) (2026-01-26) **Note:** Version bump only for package @amplitude/plugin-web-vitals-browser diff --git a/packages/plugin-web-vitals-browser/package.json b/packages/plugin-web-vitals-browser/package.json index 1c6bcd268..c72355445 100644 --- a/packages/plugin-web-vitals-browser/package.json +++ b/packages/plugin-web-vitals-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/plugin-web-vitals-browser", - "version": "1.1.9", + "version": "1.1.10-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/plugin-web-vitals-browser/src/version.ts b/packages/plugin-web-vitals-browser/src/version.ts index 1d89284b1..16e8ab664 100644 --- a/packages/plugin-web-vitals-browser/src/version.ts +++ b/packages/plugin-web-vitals-browser/src/version.ts @@ -1 +1 @@ -export const VERSION = '1.1.9'; +export const VERSION = '1.1.10-feat-zoning-alpha.0'; diff --git a/packages/segment-session-replay-plugin/CHANGELOG.md b/packages/segment-session-replay-plugin/CHANGELOG.md index 45a31be68..2aeac6c11 100644 --- a/packages/segment-session-replay-plugin/CHANGELOG.md +++ b/packages/segment-session-replay-plugin/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.0.0-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/segment-session-replay-plugin@0.0.0-beta.59...@amplitude/segment-session-replay-plugin@0.0.0-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/segment-session-replay-plugin + + + + + # [0.0.0-beta.59](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/segment-session-replay-plugin@0.0.0-beta.58...@amplitude/segment-session-replay-plugin@0.0.0-beta.59) (2026-01-26) **Note:** Version bump only for package @amplitude/segment-session-replay-plugin diff --git a/packages/segment-session-replay-plugin/package.json b/packages/segment-session-replay-plugin/package.json index 5710f8d7a..2cc4028cd 100644 --- a/packages/segment-session-replay-plugin/package.json +++ b/packages/segment-session-replay-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/segment-session-replay-plugin", - "version": "0.0.0-beta.59", + "version": "0.0.0-feat-zoning-alpha.0", "description": "Plugin for Segment's analytics.js library to support Amplitude's Session Replay.", "keywords": [ "amplitude", diff --git a/packages/segment-session-replay-plugin/src/version.ts b/packages/segment-session-replay-plugin/src/version.ts index c019d4a55..a593a2eb4 100644 --- a/packages/segment-session-replay-plugin/src/version.ts +++ b/packages/segment-session-replay-plugin/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by `pnpm version-file`. DO NOT EDIT -export const VERSION = '0.0.0-beta.59'; +export const VERSION = '0.0.0-feat-zoning-alpha.0'; diff --git a/packages/session-replay-browser/CHANGELOG.md b/packages/session-replay-browser/CHANGELOG.md index 09b5cdea3..d482bf0f4 100644 --- a/packages/session-replay-browser/CHANGELOG.md +++ b/packages/session-replay-browser/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.30.10-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/session-replay-browser@1.30.9...@amplitude/session-replay-browser@1.30.10-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/session-replay-browser + + + + + ## [1.30.9](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/session-replay-browser@1.30.8...@amplitude/session-replay-browser@1.30.9) (2026-01-26) **Note:** Version bump only for package @amplitude/session-replay-browser diff --git a/packages/session-replay-browser/package.json b/packages/session-replay-browser/package.json index a00d06650..e3fa32c74 100644 --- a/packages/session-replay-browser/package.json +++ b/packages/session-replay-browser/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/session-replay-browser", - "version": "1.30.9", + "version": "1.30.10-feat-zoning-alpha.0", "description": "", "author": "Amplitude Inc", "homepage": "https://github.com/amplitude/Amplitude-TypeScript", diff --git a/packages/session-replay-browser/src/version.ts b/packages/session-replay-browser/src/version.ts index 737c720de..3e4e97ab2 100644 --- a/packages/session-replay-browser/src/version.ts +++ b/packages/session-replay-browser/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by `pnpm version-file`. DO NOT EDIT -export const VERSION = '1.30.9'; +export const VERSION = '1.30.10-feat-zoning-alpha.0'; diff --git a/packages/unified/CHANGELOG.md b/packages/unified/CHANGELOG.md index 2c4df6cd5..17e4f16a0 100644 --- a/packages/unified/CHANGELOG.md +++ b/packages/unified/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [1.0.0-feat-zoning-alpha.0](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/unified@1.0.0-beta.32...@amplitude/unified@1.0.0-feat-zoning-alpha.0) (2026-02-03) + +**Note:** Version bump only for package @amplitude/unified + + + + + # [1.0.0-beta.32](https://github.com/amplitude/Amplitude-TypeScript/compare/@amplitude/unified@1.0.0-beta.31...@amplitude/unified@1.0.0-beta.32) (2026-01-26) **Note:** Version bump only for package @amplitude/unified diff --git a/packages/unified/package.json b/packages/unified/package.json index 69e4e5d5d..ecc455e72 100644 --- a/packages/unified/package.json +++ b/packages/unified/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/unified", - "version": "1.0.0-beta.32", + "version": "1.0.0-feat-zoning-alpha.0", "description": "Official Amplitude SDK for Web analytics, experiment, session replay, and more.", "keywords": [ "amplitude", diff --git a/packages/unified/src/version.ts b/packages/unified/src/version.ts index 4488e3a41..1423f1bae 100644 --- a/packages/unified/src/version.ts +++ b/packages/unified/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by `pnpm version-file`. DO NOT EDIT -export const VERSION = '1.0.0-beta.32'; +export const VERSION = '1.0.0-feat-zoning-alpha.0';