diff --git a/src/app/components/ATIAnalytics/atiUrl/index.ts b/src/app/components/ATIAnalytics/atiUrl/index.ts index 604dae7a99e..9df64b30616 100644 --- a/src/app/components/ATIAnalytics/atiUrl/index.ts +++ b/src/app/components/ATIAnalytics/atiUrl/index.ts @@ -1,4 +1,5 @@ import { + ACTIVATION_EVENT, CLICK_EVENT, VIEW_EVENT, VIEWABILITY_CLICK_EVENT, @@ -189,3 +190,56 @@ export const buildReverbEventModel = ({ }, }; }; + +type ActivationEventProps = { + pageIdentifier?: string; + producerName?: string; + statsDestination?: string; + experimentName: string; + experimentVariant: string; + isSignedIn?: boolean; + hashedId?: string | null; +}; + +/** + * Builds the standalone Piano/Reverb "activation" beacon fired when a user is + * activated into an Optimizely experiment, decoupled from any view/click event. + */ +export const buildActivationEventModel = ({ + pageIdentifier, + producerName, + statsDestination, + experimentName, + experimentVariant, + isSignedIn = false, + hashedId = null, +}: ActivationEventProps): ReverbBeaconConfig => ({ + params: { + page: { + destination: statsDestination, + name: pageIdentifier, + producer: producerName, + additionalProperties: { + type: 'AT', + }, + }, + user: { + isSignedIn, + hashedId, + }, + }, + eventDetails: { + eventName: ACTIVATION_EVENT, + eventPublisher: 'optimizely', + actionName: 'optimizely', + actionType: 'experiment', + background: true, + container: 'unspecified', + experimentName, + experimentVariant, + experience: { + engine_type: ['experimentation'], + engine_id: [`optimizely.${experimentName}.${experimentVariant}`], + }, + }, +}); diff --git a/src/app/components/ATIAnalytics/types.ts b/src/app/components/ATIAnalytics/types.ts index bebdbf8b4a1..124ca336b5c 100644 --- a/src/app/components/ATIAnalytics/types.ts +++ b/src/app/components/ATIAnalytics/types.ts @@ -119,7 +119,11 @@ export type ReverbUserVars = { }; export type ReverbEventDetails = { + actionName?: string; + actionType?: string; anchorElement?: HTMLElement; + background?: boolean; + container?: string; experience?: { engine_type: Array; engine_id: Array; @@ -129,8 +133,10 @@ export type ReverbEventDetails = { action: 'select' | 'view'; grouping?: string; }; - eventName: 'pageView' | 'sectionView' | 'sectionClick'; + eventName: 'pageView' | 'sectionView' | 'sectionClick' | 'activation'; eventPublisher?: string; + experimentName?: string; + experimentVariant?: string; group?: string | object; isClick?: boolean; item?: string | object; diff --git a/src/app/hooks/useOptimizelyActivationEvent/index.test.tsx b/src/app/hooks/useOptimizelyActivationEvent/index.test.tsx new file mode 100644 index 00000000000..735b28660c7 --- /dev/null +++ b/src/app/hooks/useOptimizelyActivationEvent/index.test.tsx @@ -0,0 +1,88 @@ +import { ReactNode } from 'react'; +import { + renderHook, + act, +} from '#app/components/react-testing-library-with-providers'; +import { EventTrackingContextProvider } from '#contexts/EventTrackingContext'; +import { RequestContextProvider } from '#contexts/RequestContext'; +import { ServiceContextProvider } from '#contexts/ServiceContext'; +import { ToggleContextProvider } from '#contexts/ToggleContext'; +import { STORY_PAGE } from '#app/routes/utils/pageTypes'; +import { ATIData } from '#app/components/ATIAnalytics/types'; +import { Toggles } from '#app/models/types/global'; +import sendOptimizelyActivationEvent from '#app/lib/analyticsUtils/sendOptimizelyActivationEvent'; +import useOptimizelyActivationEvent from '.'; + +jest.mock('#app/lib/analyticsUtils/sendOptimizelyActivationEvent'); + +const defaultToggles = { eventTracking: { enabled: true } } as Toggles; + +const wrapper = ({ + atiData, + children, + toggles = defaultToggles, +}: { + atiData?: ATIData; + children?: ReactNode | null; + toggles?: Toggles; +}) => ( + + + + + {children} + + + + +); + +describe('useOptimizelyActivationEvent', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('sends the activation event with the resolved ATI context when tracking is enabled', async () => { + const { result } = renderHook(() => useOptimizelyActivationEvent(), { + wrapper, + }); + + await act(async () => { + await result.current('foo', 'control'); + }); + + expect(sendOptimizelyActivationEvent).toHaveBeenCalledTimes(1); + expect(sendOptimizelyActivationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + experimentName: 'foo', + experimentVariant: 'control', + trackingIsEnabled: true, + service: 'news', + }), + ); + }); + + it('reports tracking as disabled when the eventTracking toggle is off', async () => { + const { result } = renderHook(() => useOptimizelyActivationEvent(), { + wrapper: props => + wrapper({ + ...props, + toggles: { eventTracking: { enabled: false } } as Toggles, + }), + }); + + await act(async () => { + await result.current('foo', 'control'); + }); + + expect(sendOptimizelyActivationEvent).toHaveBeenCalledWith( + expect.objectContaining({ trackingIsEnabled: false }), + ); + }); +}); diff --git a/src/app/hooks/useOptimizelyActivationEvent/index.ts b/src/app/hooks/useOptimizelyActivationEvent/index.ts new file mode 100644 index 00000000000..0c7fe555e6e --- /dev/null +++ b/src/app/hooks/useOptimizelyActivationEvent/index.ts @@ -0,0 +1,56 @@ +import { use, useCallback } from 'react'; +import { VIEW_EVENT } from '#app/lib/analyticsUtils/analytics.const'; +import extractATITrackingProps from '#app/lib/analyticsUtils/extractATITrackingProps'; +import sendOptimizelyActivationEvent from '#app/lib/analyticsUtils/sendOptimizelyActivationEvent'; +import { ServiceContext } from '#contexts/ServiceContext'; +import useTrackingToggle from '../useTrackingToggle'; + +/** + * Returns a stable callback that fires a standalone Piano/Reverb "activation" + * event for the given Optimizely experiment/variant, gathering the required + * ATI context (page, service, tracking toggle) once per render. + */ +const useOptimizelyActivationEvent = () => { + const { + pageIdentifier, + platform, + producerId, + producerName, + statsDestination, + isSignedIn, + hashedId, + } = extractATITrackingProps({ eventType: VIEW_EVENT }); + + const { trackingIsEnabled } = useTrackingToggle(); + const { service } = use(ServiceContext); + + return useCallback( + (experimentName: string, experimentVariant: string) => + sendOptimizelyActivationEvent({ + experimentName, + experimentVariant, + trackingIsEnabled, + pageIdentifier, + platform, + producerId, + producerName, + statsDestination, + service, + isSignedIn, + hashedId, + }), + [ + trackingIsEnabled, + pageIdentifier, + platform, + producerId, + producerName, + statsDestination, + service, + isSignedIn, + hashedId, + ], + ); +}; + +export default useOptimizelyActivationEvent; diff --git a/src/app/hooks/useOptimizelyVariation/activateExperiment/index.test.ts b/src/app/hooks/useOptimizelyVariation/activateExperiment/index.test.ts index baa8814344d..fba499dd017 100644 --- a/src/app/hooks/useOptimizelyVariation/activateExperiment/index.test.ts +++ b/src/app/hooks/useOptimizelyVariation/activateExperiment/index.test.ts @@ -1,5 +1,6 @@ import onClient from '#lib/utilities/onClient'; import { ReactSDKClient } from '@optimizely/react-sdk'; +import { RefObject } from 'react'; import activateExperiment from '.'; jest.mock('#lib/utilities/onClient'); @@ -18,6 +19,10 @@ describe('activateExperiment', () => { const mockExperimentName = 'foo'; const mockExperimentVariation = 'bar'; + const getActivatedExperiments = (): RefObject => ({ + current: [], + }); + it('should set a forced variation and activate experiment when on client', async () => { (onClient as jest.Mock).mockReturnValueOnce(true); mockOptimizely.onReady.mockResolvedValue({ success: true }); @@ -26,6 +31,7 @@ describe('activateExperiment', () => { optimizely: mockOptimizely as unknown as ReactSDKClient, experimentName: mockExperimentName, experimentVariation: mockExperimentVariation, + activatedExperiments: getActivatedExperiments(), }); expect(mockOptimizely.onReady).toHaveBeenCalledTimes(1); @@ -46,10 +52,54 @@ describe('activateExperiment', () => { optimizely: mockOptimizely as unknown as ReactSDKClient, experimentName: mockExperimentName, experimentVariation: mockExperimentVariation, + activatedExperiments: getActivatedExperiments(), }); expect(mockOptimizely.onReady).not.toHaveBeenCalled(); expect(mockOptimizely.setForcedVariation).not.toHaveBeenCalled(); expect(mockOptimizely.activate).not.toHaveBeenCalled(); }); + + it('should call onExperimentActivated once when activation succeeds', async () => { + (onClient as jest.Mock).mockReturnValueOnce(true); + mockOptimizely.onReady.mockResolvedValue({ success: true }); + const onExperimentActivated = jest.fn(); + + await activateExperiment({ + optimizely: mockOptimizely as unknown as ReactSDKClient, + experimentName: mockExperimentName, + experimentVariation: mockExperimentVariation, + activatedExperiments: getActivatedExperiments(), + onExperimentActivated, + }); + + expect(onExperimentActivated).toHaveBeenCalledTimes(1); + expect(onExperimentActivated).toHaveBeenCalledWith('foo', 'bar'); + }); + + it('should not activate again or call onExperimentActivated if the experiment was already activated', async () => { + (onClient as jest.Mock).mockReturnValue(true); + mockOptimizely.onReady.mockResolvedValue({ success: true }); + const onExperimentActivated = jest.fn(); + const activatedExperiments = getActivatedExperiments(); + + await activateExperiment({ + optimizely: mockOptimizely as unknown as ReactSDKClient, + experimentName: mockExperimentName, + experimentVariation: mockExperimentVariation, + activatedExperiments, + onExperimentActivated, + }); + + await activateExperiment({ + optimizely: mockOptimizely as unknown as ReactSDKClient, + experimentName: mockExperimentName, + experimentVariation: mockExperimentVariation, + activatedExperiments, + onExperimentActivated, + }); + + expect(mockOptimizely.activate).toHaveBeenCalledTimes(1); + expect(onExperimentActivated).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/hooks/useOptimizelyVariation/activateExperiment/index.ts b/src/app/hooks/useOptimizelyVariation/activateExperiment/index.ts index 877ab5ac51c..f98c53b4710 100644 --- a/src/app/hooks/useOptimizelyVariation/activateExperiment/index.ts +++ b/src/app/hooks/useOptimizelyVariation/activateExperiment/index.ts @@ -1,22 +1,32 @@ import onClient from '#lib/utilities/onClient'; import { ReactSDKClient } from '@optimizely/react-sdk'; +import { RefObject } from 'react'; type Props = { optimizely: ReactSDKClient; experimentName: string; experimentVariation: string; + activatedExperiments: RefObject; + onExperimentActivated?: ( + experimentName: string, + experimentVariation: string, + ) => void; }; const activateExperiment = async ({ optimizely, experimentName, experimentVariation, + activatedExperiments, + onExperimentActivated, }: Props) => { if (onClient() && optimizely) { const success = await optimizely?.onReady(); - if (success) { + if (success && !activatedExperiments.current.includes(experimentName)) { + activatedExperiments.current.push(experimentName); optimizely.setForcedVariation(experimentName, experimentVariation); optimizely.activate(experimentName); + onExperimentActivated?.(experimentName, experimentVariation); } } }; diff --git a/src/app/hooks/useOptimizelyVariation/useClientSide/index.test.ts b/src/app/hooks/useOptimizelyVariation/useClientSide/index.test.ts deleted file mode 100644 index 04d9b233046..00000000000 --- a/src/app/hooks/useOptimizelyVariation/useClientSide/index.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import optimizelyReactSdk, { OptimizelyDecision } from '@optimizely/react-sdk'; -import { renderHook } from '#app/components/react-testing-library-with-providers'; -import useClientSide from '.'; - -describe('useOptimizelyVariation - useClientSide', () => { - const useDecisionSpy = jest.spyOn(optimizelyReactSdk, 'useDecision'); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return a variation string when the client is ready and not timed out', () => { - useDecisionSpy.mockReturnValue([ - { variationKey: 'control' } as unknown as OptimizelyDecision, - true, - false, - ]); - - const { result } = renderHook(() => - useClientSide({ experimentName: 'correct_experiment_id' }), - ); - - expect(result.current).toEqual('control'); - }); - - it('should return a variation of null when the client is not ready and not timed out', () => { - useDecisionSpy.mockReturnValue([ - { variationKey: null } as unknown as OptimizelyDecision, - false, - false, - ]); - - const { result } = renderHook(() => - useClientSide({ experimentName: 'correct_experiment_id' }), - ); - - expect(result.current).toEqual(null); - }); - - it('should return a variation of null when the client is ready but has timed out', () => { - useDecisionSpy.mockReturnValue([ - { variationKey: null } as unknown as OptimizelyDecision, - true, - true, - ]); - - const { result } = renderHook(() => - useClientSide({ experimentName: 'correct_experiment_id' }), - ); - - expect(result.current).toEqual(null); - }); - - it('should return a variation of null when a decision is not made', () => { - useDecisionSpy.mockReturnValue([ - { variationKey: null } as unknown as OptimizelyDecision, - true, - false, - ]); - const { result } = renderHook(() => - useClientSide({ experimentName: 'wrong_experiment_id' }), - ); - - expect(result.current).toEqual(null); - }); -}); diff --git a/src/app/hooks/useOptimizelyVariation/useClientSide/index.test.tsx b/src/app/hooks/useOptimizelyVariation/useClientSide/index.test.tsx new file mode 100644 index 00000000000..f05d22a36a9 --- /dev/null +++ b/src/app/hooks/useOptimizelyVariation/useClientSide/index.test.tsx @@ -0,0 +1,169 @@ +import { ReactNode } from 'react'; +import optimizelyReactSdk, { OptimizelyDecision } from '@optimizely/react-sdk'; +import { + renderHook, + act, +} from '#app/components/react-testing-library-with-providers'; +import { EventTrackingContextProvider } from '#contexts/EventTrackingContext'; +import { RequestContextProvider } from '#contexts/RequestContext'; +import { ServiceContextProvider } from '#contexts/ServiceContext'; +import { ToggleContextProvider } from '#contexts/ToggleContext'; +import { STORY_PAGE } from '#app/routes/utils/pageTypes'; +import { Toggles } from '#app/models/types/global'; +import sendOptimizelyActivationEvent from '#app/lib/analyticsUtils/sendOptimizelyActivationEvent'; +import useClientSide from '.'; + +jest.mock('#app/lib/analyticsUtils/sendOptimizelyActivationEvent'); + +const defaultToggles = { eventTracking: { enabled: true } } as Toggles; + +const wrapper = ({ + children, + toggles = defaultToggles, +}: { + children?: ReactNode | null; + toggles?: Toggles; +}) => ( + + + + {children} + + + +); + +describe('useOptimizelyVariation - useClientSide', () => { + const useDecisionSpy = jest.spyOn(optimizelyReactSdk, 'useDecision'); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return a variation string when the client is ready and not timed out', () => { + useDecisionSpy.mockReturnValue([ + { variationKey: 'control' } as unknown as OptimizelyDecision, + true, + false, + ]); + + const { result } = renderHook( + () => useClientSide({ experimentName: 'correct_experiment_id' }), + { wrapper }, + ); + + expect(result.current).toEqual('control'); + }); + + it('should return a variation of null when the client is not ready and not timed out', () => { + useDecisionSpy.mockReturnValue([ + { variationKey: null } as unknown as OptimizelyDecision, + false, + false, + ]); + + const { result } = renderHook( + () => useClientSide({ experimentName: 'correct_experiment_id' }), + { wrapper }, + ); + + expect(result.current).toEqual(null); + }); + + it('should return a variation of null when the client is ready but has timed out', () => { + useDecisionSpy.mockReturnValue([ + { variationKey: null } as unknown as OptimizelyDecision, + true, + true, + ]); + + const { result } = renderHook( + () => useClientSide({ experimentName: 'correct_experiment_id' }), + { wrapper }, + ); + + expect(result.current).toEqual(null); + }); + + it('should return a variation of null when a decision is not made', () => { + useDecisionSpy.mockReturnValue([ + { variationKey: null } as unknown as OptimizelyDecision, + true, + false, + ]); + const { result } = renderHook( + () => useClientSide({ experimentName: 'wrong_experiment_id' }), + { wrapper }, + ); + + expect(result.current).toEqual(null); + }); + + it('should send the activation event once when a valid variation is resolved', async () => { + useDecisionSpy.mockReturnValue([ + { variationKey: 'control' } as unknown as OptimizelyDecision, + true, + false, + ]); + + await act(async () => { + renderHook( + () => useClientSide({ experimentName: 'correct_experiment_id' }), + { wrapper }, + ); + }); + + expect(sendOptimizelyActivationEvent).toHaveBeenCalledTimes(1); + expect(sendOptimizelyActivationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + experimentName: 'correct_experiment_id', + experimentVariant: 'control', + }), + ); + }); + + it('should not send the activation event when the variation is "off"', async () => { + useDecisionSpy.mockReturnValue([ + { variationKey: 'off' } as unknown as OptimizelyDecision, + true, + false, + ]); + + await act(async () => { + renderHook( + () => useClientSide({ experimentName: 'correct_experiment_id' }), + { wrapper }, + ); + }); + + expect(sendOptimizelyActivationEvent).not.toHaveBeenCalled(); + }); + + it('should not send the activation event again after a re-render', async () => { + useDecisionSpy.mockReturnValue([ + { variationKey: 'control' } as unknown as OptimizelyDecision, + true, + false, + ]); + + let rerender: (() => void) | undefined; + await act(async () => { + ({ rerender } = renderHook( + () => useClientSide({ experimentName: 'correct_experiment_id' }), + { wrapper }, + )); + }); + + await act(async () => { + rerender?.(); + }); + + expect(sendOptimizelyActivationEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/hooks/useOptimizelyVariation/useClientSide/index.ts b/src/app/hooks/useOptimizelyVariation/useClientSide/index.ts index e37c13133c9..7619bf0e0a0 100644 --- a/src/app/hooks/useOptimizelyVariation/useClientSide/index.ts +++ b/src/app/hooks/useOptimizelyVariation/useClientSide/index.ts @@ -1,6 +1,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useDecision } from '@optimizely/react-sdk'; +import useOptimizelyActivationEvent from '#hooks/useOptimizelyActivationEvent'; type Props = { experimentName: string; @@ -17,12 +18,29 @@ export default ({ experimentName, overrideAttributes = {} }: Props) => { ); const [variation, setVariation] = useState(null); + const activatedExperiments = useRef([]); + const sendActivationEvent = useOptimizelyActivationEvent(); useEffect(() => { if (isClientReady && !didTimeout) { setVariation(decision.variationKey); + + if ( + decision.variationKey && + decision.variationKey !== 'off' && + !activatedExperiments.current.includes(experimentName) + ) { + activatedExperiments.current.push(experimentName); + sendActivationEvent(experimentName, decision.variationKey); + } } - }, [isClientReady, decision.variationKey, didTimeout]); + }, [ + isClientReady, + decision.variationKey, + didTimeout, + experimentName, + sendActivationEvent, + ]); return variation; }; diff --git a/src/app/hooks/useOptimizelyVariation/useServerSide/index.test.tsx b/src/app/hooks/useOptimizelyVariation/useServerSide/index.test.tsx index 9d53f75d8f1..4f34e3ba202 100644 --- a/src/app/hooks/useOptimizelyVariation/useServerSide/index.test.tsx +++ b/src/app/hooks/useOptimizelyVariation/useServerSide/index.test.tsx @@ -1,6 +1,9 @@ import { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; import { RequestContextProvider } from '#contexts/RequestContext'; +import { ServiceContextProvider } from '#contexts/ServiceContext'; +import { ToggleContextProvider } from '#contexts/ToggleContext'; +import { EventTrackingContextProvider } from '#contexts/EventTrackingContext'; import { OptimizelyProvider, ReactSDKClient } from '@optimizely/react-sdk'; import { PageTypes, @@ -26,8 +29,13 @@ describe('useOptimizelyVariation - useServerSide', () => { const renderUseServerSide = (params: { experimentName: string; serverSideExperiments?: ServerSideExperiment[]; + withOptimizely?: boolean; }) => { - const { experimentName, serverSideExperiments } = params; + const { + experimentName, + serverSideExperiments, + withOptimizely = true, + } = params; const props = { serverSideExperiments, @@ -36,21 +44,40 @@ describe('useOptimizelyVariation - useServerSide', () => { service: 'news' as Services, pathname: 'bar', }; - const wrapper = ({ children }: PropsWithChildren) => ( - - {children} - - ); + const wrapper = ({ children }: PropsWithChildren) => { + const providers = ( + + + + + {children} + + + + + ); + + return withOptimizely ? ( + + {providers} + + ) : ( + providers + ); + }; return renderHook(() => useServerSide(experimentName), { wrapper, }); }; it('should return null if optimizely is not defined', () => { - const { result } = renderHook(() => useServerSide('foo')); + const { result } = renderUseServerSide({ + experimentName: 'foo', + withOptimizely: false, + }); expect(result.current).toEqual(null); }); @@ -154,7 +181,7 @@ describe('useOptimizelyVariation - useServerSide', () => { expect(result.current).toBeNull(); }); - it('should call activate experiment if experiment is enabled', () => { + it('should call activate experiment (via useEffect) if experiment is enabled', async () => { const mockServerSideExperiments = [ { experimentName: 'foo', @@ -163,15 +190,18 @@ describe('useOptimizelyVariation - useServerSide', () => { }, ]; - renderUseServerSide({ - serverSideExperiments: - mockServerSideExperiments as ServerSideExperiment[], - experimentName: 'foo', + await act(async () => { + renderUseServerSide({ + serverSideExperiments: + mockServerSideExperiments as ServerSideExperiment[], + experimentName: 'foo', + }); }); - expect(spyActivateExperiment).toHaveBeenCalled(); + + expect(spyActivateExperiment).toHaveBeenCalledTimes(1); }); - it('should not call activate experiment if experiment is disabled', () => { + it('should not call activate experiment if experiment is disabled', async () => { const mockServerSideExperiments = [ { experimentName: 'foo', @@ -180,26 +210,37 @@ describe('useOptimizelyVariation - useServerSide', () => { }, ]; - renderUseServerSide({ - serverSideExperiments: - mockServerSideExperiments as ServerSideExperiment[], - experimentName: 'foo', + await act(async () => { + renderUseServerSide({ + serverSideExperiments: + mockServerSideExperiments as ServerSideExperiment[], + experimentName: 'foo', + }); }); + expect(spyActivateExperiment).not.toHaveBeenCalled(); }); - it('should not re-activate the experiment on rerender', () => { - const { rerender } = renderUseServerSide({ - serverSideExperiments: [ - { experimentName: 'foo', variation: 'control', enabled: true }, - ] as ServerSideExperiment[], - experimentName: 'foo', + it('should not re-activate the experiment on rerender', async () => { + let rerender: (() => void) | undefined; + + await act(async () => { + ({ rerender } = renderUseServerSide({ + serverSideExperiments: [ + { experimentName: 'foo', variation: 'control', enabled: true }, + ] as ServerSideExperiment[], + experimentName: 'foo', + })); }); expect(spyActivateExperiment).toHaveBeenCalledTimes(1); - rerender(); - rerender(); + await act(async () => { + rerender?.(); + }); + await act(async () => { + rerender?.(); + }); expect(spyActivateExperiment).toHaveBeenCalledTimes(1); }); diff --git a/src/app/hooks/useOptimizelyVariation/useServerSide/index.ts b/src/app/hooks/useOptimizelyVariation/useServerSide/index.ts index 74c283cc160..70bec176e63 100644 --- a/src/app/hooks/useOptimizelyVariation/useServerSide/index.ts +++ b/src/app/hooks/useOptimizelyVariation/useServerSide/index.ts @@ -1,34 +1,34 @@ import { OptimizelyContext } from '@optimizely/react-sdk'; -import { useContext, useEffect } from 'react'; +import { useContext, useEffect, useRef } from 'react'; import { RequestContext } from '#app/contexts/RequestContext'; +import useOptimizelyActivationEvent from '#hooks/useOptimizelyActivationEvent'; import activateExperiment from '../activateExperiment'; export default (experimentName: string) => { const { optimizely } = useContext(OptimizelyContext); const { serverSideExperiments } = useContext(RequestContext); + const activatedExperiments = useRef([]); + const sendActivationEvent = useOptimizelyActivationEvent(); const experiment = serverSideExperiments?.find( ({ experimentName: serverSideExperiment }) => serverSideExperiment === experimentName, ); - - const { enabled, variation = null } = experiment ?? {}; - - const isActiveVariant = Boolean( - enabled && variation && variation !== 'false', - ); + const { enabled, variation } = experiment || {}; + const activeVariation = + enabled && variation && variation !== 'false' ? variation : null; useEffect(() => { - if (optimizely && isActiveVariant && variation) { + if (optimizely && activeVariation) { activateExperiment({ optimizely, experimentName, - experimentVariation: variation, + experimentVariation: activeVariation, + activatedExperiments, + onExperimentActivated: sendActivationEvent, }); } - }, [optimizely, isActiveVariant, variation, experimentName]); - - if (!optimizely || !isActiveVariant) return null; + }, [optimizely, experimentName, activeVariation, sendActivationEvent]); - return variation; + return optimizely ? activeVariation : null; }; diff --git a/src/app/hooks/usePWAInstallTracker/index.test.tsx b/src/app/hooks/usePWAInstallTracker/index.test.tsx index e9196950651..205a0f2e7f4 100644 --- a/src/app/hooks/usePWAInstallTracker/index.test.tsx +++ b/src/app/hooks/usePWAInstallTracker/index.test.tsx @@ -1,4 +1,6 @@ +import { PropsWithChildren } from 'react'; import { renderHook } from '@testing-library/react'; +import { ToggleContextProvider } from '#contexts/ToggleContext'; import * as useCustomEventTrackerModule from '../useCustomEventTracker'; import usePWAInstallTracker from '.'; @@ -8,6 +10,10 @@ const mockUseCustomEventTracker = jest.spyOn( 'default', ); +const wrapper = ({ children }: PropsWithChildren) => ( + {children} +); + describe('usePWAInstallTracker', () => { let addEventListenerSpy: jest.SpyInstance; @@ -23,7 +29,7 @@ describe('usePWAInstallTracker', () => { }); it('should initialize useCustomEventTracker with correct eventName', () => { - renderHook(() => usePWAInstallTracker()); + renderHook(() => usePWAInstallTracker(), { wrapper }); expect(mockUseCustomEventTracker).toHaveBeenCalledWith({ eventName: 'pwa-installed', @@ -31,7 +37,7 @@ describe('usePWAInstallTracker', () => { }); it('should add appinstalled event listener on mount', () => { - renderHook(() => usePWAInstallTracker()); + renderHook(() => usePWAInstallTracker(), { wrapper }); expect(addEventListenerSpy).toHaveBeenCalledWith( 'appinstalled', @@ -40,7 +46,7 @@ describe('usePWAInstallTracker', () => { }); it('should call trackEvent when appinstalled event is fired', () => { - renderHook(() => usePWAInstallTracker()); + renderHook(() => usePWAInstallTracker(), { wrapper }); expect(addEventListenerSpy.mock.calls[0][0]).toBe('appinstalled'); @@ -53,7 +59,7 @@ describe('usePWAInstallTracker', () => { }); it('should only track the event once even if appinstalled event is fired multiple times', () => { - renderHook(() => usePWAInstallTracker()); + renderHook(() => usePWAInstallTracker(), { wrapper }); const addedHandler = addEventListenerSpy.mock.calls[0][1]; diff --git a/src/app/lib/analyticsUtils/analytics.const.ts b/src/app/lib/analyticsUtils/analytics.const.ts index 928f6153e7b..4abd5f90d79 100644 --- a/src/app/lib/analyticsUtils/analytics.const.ts +++ b/src/app/lib/analyticsUtils/analytics.const.ts @@ -14,6 +14,7 @@ export const XTOR_CAMPAIGN_IDENTIFIER = 'xtor'; export const VIEW_EVENT = 'view'; export const CLICK_EVENT = 'click'; export const VIEWABILITY_CLICK_EVENT = 'select'; +export const ACTIVATION_EVENT = 'activation'; export const STATIC_ATI_VIEW_TRACKING = 'data-static-ati-view'; export const STATIC_REVERB_VIEW_TRACKING = 'data-static-reverb-view'; diff --git a/src/app/lib/analyticsUtils/sendBeacon/index.test.ts b/src/app/lib/analyticsUtils/sendBeacon/index.test.ts index 8f00ca37127..1cfd23622ca 100644 --- a/src/app/lib/analyticsUtils/sendBeacon/index.test.ts +++ b/src/app/lib/analyticsUtils/sendBeacon/index.test.ts @@ -153,6 +153,51 @@ describe('sendBeacon', () => { ); }); + it('should call Reverb userActionEvent with activation fields for an activation event', async () => { + const reverbActivationConfig = { + params: { + page: 'page', + user: '1234-5678', + }, + eventDetails: { + eventName: 'activation', + eventPublisher: 'optimizely', + actionName: 'optimizely', + actionType: 'experiment', + background: true, + container: 'unspecified', + experimentName: 'foo', + experimentVariant: 'bar', + experience: { + engine_type: ['experimentation'], + engine_id: ['optimizely.foo.bar'], + }, + }, + } as unknown as ReverbBeaconConfig; + + await sendBeacon(reverbActivationConfig); + + expect(reverbMock.userActionEvent).toHaveBeenCalledTimes(1); + expect(reverbMock.userActionEvent).toHaveBeenCalledWith( + 'optimizely', + 'optimizely', + { + actionType: 'experiment', + background: true, + container: 'unspecified', + experimentName: 'foo', + experimentVariant: 'bar', + experience: { + engine_type: ['experimentation'], + engine_id: ['optimizely.foo.bar'], + }, + }, + undefined, + undefined, + undefined, + ); + }); + it(`should not call Reverb when not on client`, async () => { isOnClient = false; diff --git a/src/app/lib/analyticsUtils/sendBeacon/index.ts b/src/app/lib/analyticsUtils/sendBeacon/index.ts index 26c43469d90..7290d51d0c2 100644 --- a/src/app/lib/analyticsUtils/sendBeacon/index.ts +++ b/src/app/lib/analyticsUtils/sendBeacon/index.ts @@ -27,18 +27,33 @@ const reverbComponentTracking = async ({ eventDetails, }: ReverbComponentTrackingProps) => { const { + actionName = '', + actionType, anchorElement, + background, + container, experience, event, eventPublisher, + experimentName, + experimentVariant, group, isClick, item, originalEvent, } = eventDetails; - const actionName = ''; - const actionAdditionalLabels = { event, group, item, experience }; + const actionAdditionalLabels = { + event, + group, + item, + experience, + ...(actionType && { actionType }), + ...(background !== undefined && { background }), + ...(container && { container }), + ...(experimentName && { experimentName }), + ...(experimentVariant && { experimentVariant }), + }; return reverbInstance.userActionEvent( eventPublisher, @@ -54,6 +69,7 @@ const reverbHandlers = { pageView: reverbPageViews, sectionView: reverbComponentTracking, sectionClick: reverbComponentTracking, + activation: reverbComponentTracking, }; const callReverb = async (eventDetails: ReverbEventDetails) => { diff --git a/src/app/lib/analyticsUtils/sendOptimizelyActivationEvent/index.test.ts b/src/app/lib/analyticsUtils/sendOptimizelyActivationEvent/index.test.ts new file mode 100644 index 00000000000..7218569a0e2 --- /dev/null +++ b/src/app/lib/analyticsUtils/sendOptimizelyActivationEvent/index.test.ts @@ -0,0 +1,75 @@ +import sendBeacon from '../sendBeacon'; +import sendOptimizelyActivationEvent from '.'; + +jest.mock('../sendBeacon'); + +describe('sendOptimizelyActivationEvent', () => { + const validProps = { + experimentName: 'foo', + experimentVariant: 'control', + trackingIsEnabled: true, + pageIdentifier: 'page-identifier', + platform: 'canonical' as const, + producerId: 'producer-id', + producerName: 'producer-name', + statsDestination: 'stats-destination', + service: 'news' as const, + isSignedIn: true, + hashedId: 'hashed-id', + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('builds and sends the activation beacon when all required props are present', async () => { + await sendOptimizelyActivationEvent(validProps); + + expect(sendBeacon).toHaveBeenCalledTimes(1); + expect(sendBeacon).toHaveBeenCalledWith( + expect.objectContaining({ + eventDetails: expect.objectContaining({ + eventName: 'activation', + experimentName: 'foo', + experimentVariant: 'control', + }), + }), + ); + }); + + it('does not send when tracking is disabled', async () => { + await sendOptimizelyActivationEvent({ + ...validProps, + trackingIsEnabled: false, + }); + + expect(sendBeacon).not.toHaveBeenCalled(); + }); + + it('does not send when experimentVariant is falsy', async () => { + await sendOptimizelyActivationEvent({ + ...validProps, + experimentVariant: null, + }); + + expect(sendBeacon).not.toHaveBeenCalled(); + }); + + it('does not send when experimentVariant is "off"', async () => { + await sendOptimizelyActivationEvent({ + ...validProps, + experimentVariant: 'off', + }); + + expect(sendBeacon).not.toHaveBeenCalled(); + }); + + it('does not send when a required ATI prop is missing', async () => { + await sendOptimizelyActivationEvent({ + ...validProps, + pageIdentifier: undefined, + }); + + expect(sendBeacon).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/lib/analyticsUtils/sendOptimizelyActivationEvent/index.ts b/src/app/lib/analyticsUtils/sendOptimizelyActivationEvent/index.ts new file mode 100644 index 00000000000..16d13c1b1b6 --- /dev/null +++ b/src/app/lib/analyticsUtils/sendOptimizelyActivationEvent/index.ts @@ -0,0 +1,67 @@ +import { Platforms, Services } from '#app/models/types/global'; +import { buildActivationEventModel } from '#app/components/ATIAnalytics/atiUrl'; +import sendBeacon from '../sendBeacon'; + +type Props = { + experimentName: string; + experimentVariant?: string | null; + trackingIsEnabled: boolean; + pageIdentifier?: string; + platform?: Platforms; + producerId?: string; + producerName?: string; + statsDestination?: string; + service?: Services; + isSignedIn?: boolean; + hashedId?: string | null; +}; + +/** + * Sends a standalone Piano/Reverb "activation" beacon at the point a user is + * activated into an Optimizely experiment, decoupled from any view/click event. + * Callers are responsible for only invoking this once per activation (see the + * ref-based dedupe in `useServerSide`/`useClientSide`). + */ +const sendOptimizelyActivationEvent = async ({ + experimentName, + experimentVariant, + trackingIsEnabled, + pageIdentifier, + platform, + producerId, + producerName, + statsDestination, + service, + isSignedIn, + hashedId, +}: Props) => { + if (!trackingIsEnabled || !experimentVariant || experimentVariant === 'off') { + return; + } + + const shouldSendEvent = [ + experimentName, + pageIdentifier, + platform, + producerId, + producerName, + service, + statsDestination, + ].every(Boolean); + + if (!shouldSendEvent) return; + + const reverbParams = buildActivationEventModel({ + pageIdentifier, + producerName, + statsDestination, + experimentName, + experimentVariant, + isSignedIn, + hashedId, + }); + + await sendBeacon(reverbParams); +}; + +export default sendOptimizelyActivationEvent;