diff --git a/packages/react/src/__tests__/__snapshots__/index.test.ts.snap b/packages/react/src/__tests__/__snapshots__/index.test.ts.snap index 64eee47b3..4f471177c 100644 --- a/packages/react/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/index.test.ts.snap @@ -247,20 +247,22 @@ Object { [Function], ], "currentPageCallData": null, - "currentUniqueViewId": null, "integrations": Map {}, "isReady": false, - "lastFromParameter": null, - "lastPageLocation": "", "platform": "web", - "previousUniqueViewId": null, "setStoragePromise": Promise {}, "setStoragePromiseResolve": [Function], "setUserPromise": Promise {}, "setUserPromiseResolve": [Function], "storage": null, - "uniqueViewIdStorage": null, "userInstance": null, + "webContextStateManager": WebContextStateManager { + "currentUniqueViewId": null, + "lastFromParameter": null, + "lastPageLocation": "", + "previousUniqueViewId": null, + "uniqueViewIdStorage": null, + }, }, "currencyFormatter": [Function], "getListingSeoMetadataParams": [Function], diff --git a/packages/react/src/analytics/WebContextStateManager.ts b/packages/react/src/analytics/WebContextStateManager.ts new file mode 100644 index 000000000..3a98aa44b --- /dev/null +++ b/packages/react/src/analytics/WebContextStateManager.ts @@ -0,0 +1,131 @@ +import { + type EventContextData, + type EventData, + type EventProperties, + type TrackTypesValues, + utils, +} from '@farfetch/blackout-analytics'; +import UniqueViewIdStorage from './uniqueViewIdStorage/UniqueViewIdStorage.js'; +import type { ProcessedContextWeb } from './context.js'; + +/** + * Helper class used by analytics to manage state that will + * be added to the context of each event. + */ +export default class WebContextStateManager { + // Instance that manages the storage of unique view ids. + uniqueViewIdStorage: UniqueViewIdStorage | null = null; + // Unique id for the previous page. Generated internally if it is not passed in event properties. Used by Omnitracking. + previousUniqueViewId: ProcessedContextWeb[typeof utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID] = + null; + // Unique id for the current page. Generated internally if it is not passed in event properties. Used by Omnitracking. + currentUniqueViewId: ProcessedContextWeb[typeof utils.ANALYTICS_UNIQUE_VIEW_ID] = + null; + // Last used `from` parameter in either a page or track call to analytics. Used by Omnitracking. + lastFromParameter: ProcessedContextWeb[typeof utils.LAST_FROM_PARAMETER_KEY] = + null; + // Since document.referrer stays the same on single page applications, + // we have this alternative that will hold the previous page location + // based on page track calls with `analyticsWeb.page()`. + lastPageLocation: string | undefined; + + /** + * Initializes last page location with the document.referrer if it is + * available. + */ + constructor() { + this.lastPageLocation = + typeof document !== 'undefined' ? document.referrer : undefined; + } + + /** + * Updated last page location which will be used to calculate + * the pageLocationReferrer context parameter. This is separate + * from other state updates since it normally needs to be done after + * the event is dispatched to analytics. + */ + updateLastPageLocation() { + const locationHref = window.location.href; + + if (this.lastPageLocation !== locationHref) { + // The 'pageLocationReferrer' should not change on loadIntegration and onSetUser events. + this.lastPageLocation = locationHref; + } + } + + /** + * Gets the current snapshot of the managed state. + * + * @returns Current snapshot of the managed state. + */ + getSnapshot() { + return { + [utils.ANALYTICS_UNIQUE_VIEW_ID]: this.currentUniqueViewId, + [utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]: this.previousUniqueViewId, + [utils.LAST_FROM_PARAMETER_KEY]: this.lastFromParameter, + [utils.PAGE_LOCATION_REFERRER_KEY]: this.lastPageLocation, + }; + } + + /** + * Updates relevant state based on the passed page event that will be tracked. + * + * @param _event - Event name. Not used at the moment but added as it might be useful in the future. + * @param properties - Properties of the event. + * @param _eventContext - Event context. Not used at the moment but added as it might be useful in the future. + */ + updateStateFromPageEvent( + _event: string, + properties?: EventProperties, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _eventContext?: EventContextData, + ) { + // Store the previousUniqueViewId with the last current one. + this.previousUniqueViewId = this.currentUniqueViewId; + + // Generates a new unique view ID for the new page track, + // so it can be used when the context of the current event is processed by `this.context()` (super overridden) method. + const newUniqueViewId = utils.getUniqueViewId({ + properties, + } as EventData); + + // Sets the current unique view ID on the storage for the current URL + this.uniqueViewIdStorage?.set(window.location.href, newUniqueViewId); + + // Saves the current unique view ID for the next events + this.currentUniqueViewId = newUniqueViewId; + } + + /** + * Updates relevant state based on the track event that will be tracked. + * + * @param _event - Event name. Not used at the moment but added as it might be useful in the future. + * @param properties - Properties of the event. + * @param _eventContext - Event context. Not used at the moment but added as it might be useful in the future. + */ + updateStateFromTrackEvent( + _event: string, + properties?: EventProperties | undefined, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _eventContext?: EventContextData | undefined, + ) { + // Always set the `lastFromParameter` with what comes from the current event (pageview or not), + // so if there's a pageview being tracked right after this one, + // it will send the correct `navigatedFrom` parameter. + // Here we always set the value even if it comes `undefined` or `null`, + // otherwise we could end up with a stale `lastFromParameter` if some events are tracked + // without the `from` parameter. + this.lastFromParameter = (properties?.from as string) || null; + } + + /** + * Initializes the uniqueViewId storage based on the referrer, in case the user + * opens a link of the website in a new tab. + * document.referrer will point to the original URL from the previous tab, + * so we try to grab a uniqueViewId based on that to keep the user journey correct in terms of tracking. + */ + initialize() { + this.uniqueViewIdStorage = new UniqueViewIdStorage(); + this.currentUniqueViewId = this.uniqueViewIdStorage.get(document.referrer); + } +} diff --git a/packages/react/src/analytics/__tests__/analytics.test.ts b/packages/react/src/analytics/__tests__/analytics.test.ts index 7cb0ce47f..d4b8fff12 100644 --- a/packages/react/src/analytics/__tests__/analytics.test.ts +++ b/packages/react/src/analytics/__tests__/analytics.test.ts @@ -2,16 +2,31 @@ import analytics from '../analytics.js'; import AnalyticsCore, { TrackType as analyticsTrackTypes, type ConsentData, + type EventData, FromParameterType, type IntegrationOptions, integrations, PageType, + type TrackTypesValues, + utils, } from '@farfetch/blackout-analytics'; import TestStorage from 'test-storage'; +import WebContextStateManager from '../WebContextStateManager.js'; import type { WebContext } from '../context.js'; console.error = jest.fn(); +// Change window to allow modifications to href +// that are needed by some tests. +// eslint-disable-next-line no-global-assign +window = Object.create(window); +Object.defineProperty(window, 'location', { + value: { + href: 'http://localhost', + }, + writable: true, +}); + class LoadableIntegration extends integrations.Integration { static override shouldLoad() { return true; @@ -28,7 +43,6 @@ class MarketingIntegration extends integrations.Integration override track = jest.fn(); } -const mockUrl = 'https://api.blackout.com/en-pt/shopping/woman/gucci'; const mockEvent = 'myEvent'; const mockEventProperties = {}; const mockEventContext = { culture: 'pt-PT' }; @@ -36,6 +50,9 @@ const mockAnalyticsContext = { library: { version: '1.0.0', name: '@farfech-package' }, }; +// @ts-expect-error trackInternal is protected +const coreTrackSpy = jest.spyOn(AnalyticsCore.prototype, 'trackInternal'); + describe('analytics web', () => { beforeEach(async () => { jest.clearAllMocks(); @@ -45,10 +62,8 @@ describe('analytics web', () => { // @ts-expect-error analytics.integrations.clear(); analytics.currentPageCallData = null; - analytics.uniqueViewIdStorage = null; - analytics.currentUniqueViewId = null; - analytics.previousUniqueViewId = null; - analytics.lastFromParameter = null; + // @ts-expect-error Force reset of web context state + analytics.webContextStateManager = new WebContextStateManager(); await analytics.setStorage(new TestStorage()); await analytics.setUser(123); @@ -158,32 +173,143 @@ describe('analytics web', () => { }); describe('UniqueViewId and PreviousUniqueViewId', () => { - it('Should persist the newly generated uniqueViewId in storage', async () => { - Object.defineProperty(window, 'location', { - value: { href: mockUrl }, - }); - + it('Should send the correct values for page views when not done consecutively', async () => { await analytics.ready(); - const storage = analytics.uniqueViewIdStorage; + await analytics.page(mockEvent, mockEventProperties, mockEventContext); + + const firstTrackData = ( + coreTrackSpy.mock.calls[0] as EventData[] + )[0]; + + const firstTrackDataPreviousUniqueViewId = ( + firstTrackData!.context as WebContext + ).web[utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]; + + const firstTrackDataUniqueViewId = (firstTrackData!.context as WebContext) + .web[utils.ANALYTICS_UNIQUE_VIEW_ID]; + + expect(firstTrackDataPreviousUniqueViewId).toBeNull(); + expect(firstTrackDataUniqueViewId).toEqual(expect.any(String)); + + jest.clearAllMocks(); await analytics.page(mockEvent, mockEventProperties, mockEventContext); - expect(analytics.currentUniqueViewId).not.toBeNull(); - expect(storage?.get(mockUrl)).toBe(analytics.currentUniqueViewId); + const secondTrackData = ( + coreTrackSpy.mock.calls[0] as EventData[] + )[0]; - const previousUniqueViewId = analytics.currentUniqueViewId; + const secondTrackDataPreviousUniqueViewId = ( + secondTrackData!.context as WebContext + ).web[utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]; - const newPageUrl = `${mockUrl}/foo`; + const secondTrackDataUniqueViewId = ( + secondTrackData!.context as WebContext + ).web[utils.ANALYTICS_UNIQUE_VIEW_ID]; - Object.defineProperty(window, 'location', { - value: { href: newPageUrl }, - }); + expect(secondTrackDataPreviousUniqueViewId).toBe( + firstTrackDataUniqueViewId, + ); + + expect(secondTrackDataUniqueViewId).toEqual(expect.any(String)); + + expect(secondTrackDataUniqueViewId).not.toBe( + secondTrackDataPreviousUniqueViewId, + ); + }); + + it('Should send the correct values for each page view when they are done consecutively', async () => { + await analytics.ready(); + + const firstPagePromise = analytics.page( + mockEvent, + mockEventProperties, + mockEventContext, + ); + + const secondPagePromise = analytics.page( + mockEvent, + mockEventProperties, + mockEventContext, + ); + + await Promise.all([firstPagePromise, secondPagePromise]); + + const firstTrackData = ( + coreTrackSpy.mock.calls[0] as EventData[] + )[0]; + const secondTrackData = ( + coreTrackSpy.mock.calls[1] as EventData[] + )[0]; + + const firstTrackDataPreviousUniqueViewId = ( + firstTrackData!.context as WebContext + ).web[utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]; + + const firstTrackDataUniqueViewId = (firstTrackData!.context as WebContext) + .web[utils.ANALYTICS_UNIQUE_VIEW_ID]; + + const secondTrackDataPreviousUniqueViewId = ( + secondTrackData!.context as WebContext + ).web[utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]; + + const secondTrackDataUniqueViewId = ( + secondTrackData!.context as WebContext + ).web[utils.ANALYTICS_UNIQUE_VIEW_ID]; + + expect(firstTrackDataPreviousUniqueViewId).toBeNull(); + + expect(firstTrackDataUniqueViewId).toEqual(expect.any(String)); + + expect(secondTrackDataPreviousUniqueViewId).toBe( + firstTrackDataUniqueViewId, + ); + + expect(secondTrackDataUniqueViewId).toEqual(expect.any(String)); + + expect(secondTrackDataUniqueViewId).not.toBe( + secondTrackDataPreviousUniqueViewId, + ); + }); + + it('Should send the correct values for tracks that are done after a page view', async () => { + await analytics.ready(); await analytics.page(mockEvent, mockEventProperties, mockEventContext); - expect(analytics.previousUniqueViewId).toBe(previousUniqueViewId); - expect(analytics.currentUniqueViewId).not.toBe(previousUniqueViewId); + const firstTrackData = ( + coreTrackSpy.mock.calls[0] as EventData[] + )[0]; + + const firstTrackDataPreviousUniqueViewId = ( + firstTrackData!.context as WebContext + ).web[utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]; + + const firstTrackDataUniqueViewId = (firstTrackData!.context as WebContext) + .web[utils.ANALYTICS_UNIQUE_VIEW_ID]; + + jest.clearAllMocks(); + + await analytics.track(mockEvent, mockEventProperties, mockEventContext); + + const secondTrackData = ( + coreTrackSpy.mock.calls[0] as EventData[] + )[0]; + + const secondTrackDataPreviousUniqueViewId = ( + secondTrackData!.context as WebContext + ).web[utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]; + + const secondTrackDataUniqueViewId = ( + secondTrackData!.context as WebContext + ).web[utils.ANALYTICS_UNIQUE_VIEW_ID]; + + expect(secondTrackDataPreviousUniqueViewId).toBe( + firstTrackDataPreviousUniqueViewId, + ); + + expect(secondTrackDataUniqueViewId).toEqual(firstTrackDataUniqueViewId); }); }); @@ -231,9 +357,9 @@ describe('analytics web', () => { expect.objectContaining({ context: expect.objectContaining({ web: expect.objectContaining({ - __uniqueViewId: expect.any(String), - __previousUniqueViewId: expect.any(String), - __lastFromParameter: FromParameterType.Bag, + [utils.ANALYTICS_UNIQUE_VIEW_ID]: expect.any(String), + [utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID]: expect.any(String), + [utils.LAST_FROM_PARAMETER_KEY]: FromParameterType.Bag, }), }), }), @@ -252,7 +378,7 @@ describe('analytics web', () => { expect.objectContaining({ context: expect.objectContaining({ web: expect.objectContaining({ - __lastFromParameter: null, + [utils.LAST_FROM_PARAMETER_KEY]: null, }), }), }), @@ -275,9 +401,6 @@ describe('analytics web', () => { }); it('Should extend the `track() method for tracking of pages`', async () => { - // @ts-expect-error - const coreTrackSpy = jest.spyOn(AnalyticsCore.prototype, 'trackInternal'); - await analytics.page(mockEvent, mockEventProperties, mockEventContext); expect(coreTrackSpy).toHaveBeenCalledWith( @@ -301,12 +424,6 @@ describe('analytics web', () => { new (analytics.constructor as { new (): typeof analytics; })(); - // @ts-expect-error - const coreTrackSpy = jest.spyOn(AnalyticsCore.prototype, 'trackInternal'); - - beforeEach(() => { - coreTrackSpy.mockClear(); - }); it('Should retrieve pageLocationReferrer value from origin on first page view', async () => { const origin = 'www.example.com'; diff --git a/packages/react/src/analytics/analytics.ts b/packages/react/src/analytics/analytics.ts index 0e2ba2128..4433670a8 100644 --- a/packages/react/src/analytics/analytics.ts +++ b/packages/react/src/analytics/analytics.ts @@ -1,6 +1,5 @@ import { get } from 'lodash-es'; import Analytics, { - type ContextData, type EventContextData, type EventData, type EventProperties, @@ -8,13 +7,9 @@ import Analytics, { PlatformType, TrackType, type TrackTypesValues, - utils, } from '@farfetch/blackout-analytics'; -import UniqueViewIdStorage from './uniqueViewIdStorage/UniqueViewIdStorage.js'; -import webContext, { - type ProcessedContextWeb, - type WebContext, -} from './context.js'; +import webContext, { type WebContext } from './context.js'; +import WebContextStateManager from './WebContextStateManager.js'; const { name: PACKAGE_NAME, @@ -27,21 +22,16 @@ const { * documentation to know the inherited methods from Analytics. */ class AnalyticsWeb extends Analytics { + // Instance variable that manages state that will be used + // to populate the context.web of all events so that + // integrations can use. + private webContextStateManager: WebContextStateManager; currentPageCallData: EventData | null = null; - uniqueViewIdStorage: UniqueViewIdStorage | null = null; - previousUniqueViewId: ProcessedContextWeb[typeof utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID] = - null; - currentUniqueViewId: ProcessedContextWeb[typeof utils.ANALYTICS_UNIQUE_VIEW_ID] = - null; - lastFromParameter: ProcessedContextWeb[typeof utils.LAST_FROM_PARAMETER_KEY] = - null; - lastPageLocation: string | undefined; constructor() { super(PlatformType.Web); - this.lastPageLocation = - typeof document !== 'undefined' ? document.referrer : undefined; + this.webContextStateManager = new WebContextStateManager(); // Add default contexts for the web platform this.useContext(webContext); @@ -77,11 +67,11 @@ class AnalyticsWeb extends Analytics { * * @returns Value for the key in context or the whole context data if key is not specified. */ - override context(): Promise; + override context(): Promise; override context(key: string): Promise; override async context(key?: string) { - const context = await super.context(); - const processedContext = this.processContext(context as WebContext); + const context = (await super.context()) as WebContext; + const processedContext = this.processContext(context); return key ? get(processedContext, key) : processedContext; } @@ -98,22 +88,31 @@ class AnalyticsWeb extends Analytics { version: `${context.library.name}@${context.library.version};${PACKAGE_NAME}@${PACKAGE_VERSION};`, }; - if (context.web) { - context.web[utils.ANALYTICS_UNIQUE_VIEW_ID] = this.currentUniqueViewId; - context.web[utils.ANALYTICS_PREVIOUS_UNIQUE_VIEW_ID] = - this.previousUniqueViewId; - context.web[utils.LAST_FROM_PARAMETER_KEY] = this.lastFromParameter; - - // Since document.referrer stays the same on single page applications, - // we have this alternative that will hold the previous page location - // based on page track calls with `analyticsWeb.page()`. - context.web[utils.PAGE_LOCATION_REFERRER_KEY] = this.lastPageLocation; - } + const webContextStateSnapshot = this.webContextStateManager.getSnapshot(); + + this.decorateWebContext(context, webContextStateSnapshot); } return context; } + /** + * Decorates a context with the passed-in state snapshot. + * The state is managed by this.webContextStateManager instance and + * a snapshot is retrieved via getSnapshot() method. + * + * @param context - Context to decorate. + * @param webContextState - Snapshot of the state used to decorate the context with. + */ + private decorateWebContext( + context: WebContext, + webContextState: ReturnType, + ) { + if (context.web) { + Object.assign(context.web, webContextState); + } + } + /** * Stores the lastFromParameter if available, so it can be used on the next event's context. * @param event - Name of the event. @@ -128,28 +127,43 @@ class AnalyticsWeb extends Analytics { properties?: EventProperties | undefined, eventContext?: EventContextData | undefined, ) { - // Always set the `lastFromParameter` with what comes from the current event (pageview or not), - // so if there's a pageview being tracked right after this one, - // it will send the correct `navigatedFrom` parameter. - // Here we always set the value even if it comes `undefined` or `null`, - // otherwise we could end up with a stale `lastFromParameter` if some events are tracked - // without the `from` parameter. - this.lastFromParameter = (properties?.from as string) || null; + this.webContextStateManager.updateStateFromTrackEvent( + event, + properties, + eventContext, + ); - await super.track(event, properties, eventContext); + const currentWebStateSnapshot = this.webContextStateManager.getSnapshot(); - this.updatePageReferrer(); + const trackEventData = await this.getTrackEventData( + TrackType.Track, + event, + properties, + eventContext, + ); - return this; - } + // Decorate the context.web with the state + // snapshot before dispatching the event. + // This will override some properties from + // the context.web object with the correct + // values for the event since `getTrackEventData` + // will call `context` method which might + // be updated by other page calls. + this.decorateWebContext( + trackEventData.context as WebContext, + currentWebStateSnapshot, + ); - updatePageReferrer() { - const locationHref = window.location.href; + await super.trackInternal(trackEventData); - if (this.lastPageLocation !== locationHref) { - // The 'pageLocationReferrer' should not change on loadIntegration and onSetUser events. - this.lastPageLocation = locationHref; - } + // Always update page location after dispatching the + // event so integrations will see the previous value + // instead of the newly set value from this page call. + // This is used by GA4 to properly track page views + // in an SPA application. + this.webContextStateManager.updateLastPageLocation(); + + return this; } /** @@ -167,20 +181,13 @@ class AnalyticsWeb extends Analytics { properties?: EventProperties, eventContext?: EventContextData, ) { - // Store the previousUniqueViewId with the last current one. - this.previousUniqueViewId = this.currentUniqueViewId; - - // Generates a new unique view ID for the new page track, - // so it can be used when the context of the current event is processed by `this.context()` (super overridden) method. - const newUniqueViewId = utils.getUniqueViewId({ + this.webContextStateManager.updateStateFromPageEvent( + event, properties, - } as EventData); - - // Sets the current unique view ID on the storage for the current URL - this.uniqueViewIdStorage?.set(window.location.href, newUniqueViewId); + eventContext, + ); - // Saves the current unique view ID for the next events - this.currentUniqueViewId = newUniqueViewId; + const currentWebStateSnapshot = this.webContextStateManager.getSnapshot(); // Override the last page call data with the current one const pageEventData = await this.getTrackEventData( @@ -190,11 +197,28 @@ class AnalyticsWeb extends Analytics { eventContext, ); + // Decorate the context.web with the state + // snapshot before dispatching the event. + // This will override some properties from + // the context.web object with the correct + // values for the event since `getTrackEventData` + // will call `context` method which might + // be updated by other page calls. + this.decorateWebContext( + pageEventData.context as WebContext, + currentWebStateSnapshot, + ); + this.currentPageCallData = pageEventData; await super.trackInternal(pageEventData); - this.updatePageReferrer(); + // Always update page location after dispatching the + // event so integrations will see the previous value + // instead of the newly set value from this page call. + // This is used by GA4 to properly track page views + // in an SPA application. + this.webContextStateManager.updateLastPageLocation(); return this; } @@ -202,17 +226,12 @@ class AnalyticsWeb extends Analytics { /** * When webAnalytics is ready to start initializing the integrations, it means that * all conditions are met to access the document object. - * Initializes the uniqueViewId storage based on the referrer, in case the user - * opens a link of the website in a new tab. - * document.referrer will point to the original URL from the previous tab, - * so we try to grab a uniqueViewId based on that to keep the user journey correct in terms of tracking. * * @returns - Promise that will resolve with the instance that was used when calling this method to allow * chaining. */ override async ready() { - this.uniqueViewIdStorage = new UniqueViewIdStorage(); - this.currentUniqueViewId = this.uniqueViewIdStorage.get(document.referrer); + this.webContextStateManager.initialize(); return await super.ready(); }