Skip to content

Commit d74b78a

Browse files
LukasFrmalex-maganaelvinasv
authored
[WS-3079]: Send a Piano activation event when a user is activated into an Optimizely experiment (#14293)
* feat: Piano & Optimizely activation event sync; tests * fix: activation event wiring, tests * fix: add tracking state toggle in tests * fix: hande unsuccessful onReady resolution in activateExperiment; remove redundant activation properties * feat: implement activation event tracking in the decision event listener * feat: refactor activation event structure to use personalisation object; related tests * fix: move activatedExperiments from server side; rename sendOptimizelyActivationEvent --> sendExperimentActivationEvent * feat: update event structure and related types * fix: typo * chore: remove unused useOptimizelyActivationEvent hook, superseded by DECISION listener [copilot] * fix: remove redundant personalisation * refactor: activationTrackingData naming * refactor: update engine_id structure * test: reuse AllTheProviders --------- Co-authored-by: Alex Magana <alex.magana@andela.com> Co-authored-by: Elvinas Valenas <elvinas.valenas.ext@bbc.co.uk>
1 parent eaa8d8d commit d74b78a

22 files changed

Lines changed: 755 additions & 80 deletions

File tree

src/app/components/ATIAnalytics/atiUrl/index.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import {
2+
ACTIVATION_EVENT,
3+
ACTIVATION_EVENT_INTERACTION_TYPE,
4+
ACTIVATION_EVENT_SERVE_ACTION,
5+
ACTIVATION_EVENT_SPEC_ID,
6+
ACTIVATION_EVENT_SPEC_VERSION,
27
CLICK_EVENT,
38
VIEW_EVENT,
49
VIEWABILITY_CLICK_EVENT,
510
} from '#app/lib/analyticsUtils/analytics.const';
611
import { getEnvConfig } from '#app/lib/utilities/getEnvConfig';
12+
import { Platforms } from '#app/models/types/global';
713
import {
814
getAppType,
915
getHref,
@@ -189,3 +195,69 @@ export const buildReverbEventModel = ({
189195
},
190196
};
191197
};
198+
199+
type ActivationEventProps = {
200+
pageIdentifier?: string;
201+
platform?: Platforms;
202+
appName?: string;
203+
producerName?: string;
204+
statsDestination?: string;
205+
experimentName: string;
206+
experimentVariant: string;
207+
isSignedIn?: boolean;
208+
hashedId?: string | null;
209+
};
210+
211+
/**
212+
* Builds the standalone Piano/Reverb "activation" beacon fired when a user is
213+
* activated into an Optimizely experiment, decoupled from any view/click event.
214+
* Follows the "Activation (v1.0.1) on Web" event-catalogue spec (viewability model),
215+
* spec ID ACTIVATION_EVENT_SPEC_ID - see https://broxy.tools.bbc.co.uk/bbc-event-catalogue/xbbc/viewability-events/specs/experiment/activation-web/1.0.1/
216+
*/
217+
export const buildActivationEventModel = ({
218+
pageIdentifier,
219+
platform,
220+
appName,
221+
producerName,
222+
statsDestination,
223+
experimentName,
224+
experimentVariant,
225+
isSignedIn = false,
226+
hashedId = null,
227+
}: ActivationEventProps): ReverbBeaconConfig => ({
228+
params: {
229+
page: {
230+
destination: statsDestination,
231+
name: pageIdentifier,
232+
producer: producerName,
233+
additionalProperties: {
234+
type: 'AT',
235+
app_name: platform === 'app' ? `${appName}-app` : appName,
236+
app_type: getAppType(platform),
237+
},
238+
},
239+
user: {
240+
isSignedIn,
241+
hashedId,
242+
},
243+
},
244+
eventDetails: {
245+
eventName: ACTIVATION_EVENT,
246+
eventPublisher: 'viewability',
247+
event: {
248+
category: 'viewability',
249+
action: ACTIVATION_EVENT_SERVE_ACTION,
250+
// Identifies this 'serve' event as an activation event, pending a dedicated event_action value in the spec
251+
interaction_type: ACTIVATION_EVENT_INTERACTION_TYPE,
252+
spec_id: ACTIVATION_EVENT_SPEC_ID,
253+
spec_version: ACTIVATION_EVENT_SPEC_VERSION,
254+
},
255+
group: {
256+
type: 'experiment',
257+
name: 'optimizely',
258+
},
259+
experience: {
260+
engine_id: [`optimizely.${experimentName}.${experimentVariant}`],
261+
},
262+
},
263+
});

src/app/components/ATIAnalytics/types.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export type ReverbPageVars = {
104104
name?: string | null;
105105
additionalProperties?: {
106106
app_name?: string | null;
107+
app_type?: string | null;
107108
content_language?: string | null;
108109
type?: string | null;
109110
};
@@ -119,22 +120,30 @@ export type ReverbUserVars = {
119120
};
120121

121122
export type ReverbEventDetails = {
123+
actionName?: string;
122124
anchorElement?: HTMLElement;
125+
background?: boolean;
126+
container?: string;
123127
experience?: {
124-
engine_type: Array<string>;
128+
engine_type?: Array<string>;
125129
engine_id: Array<string>;
126130
};
127131
event?: {
128132
category: string;
129-
action: 'select' | 'view';
133+
action: 'select' | 'view' | 'serve';
130134
grouping?: string;
135+
interaction_type?: string;
136+
spec_id?: string;
137+
spec_version?: string;
131138
};
132-
eventName: 'pageView' | 'sectionView' | 'sectionClick';
139+
eventName: 'pageView' | 'sectionView' | 'sectionClick' | 'activation';
133140
eventPublisher?: string;
134141
group?: string | object;
135142
isClick?: boolean;
136143
item?: string | object;
137144
originalEvent?: Event;
145+
// Appended to 'actionName' by Reverb to form the 'creation' slot as 'actionName~type'
146+
type?: string;
138147
};
139148

140149
export type ReverbBeaconConfig = {

src/app/components/react-testing-library-with-providers.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ import {
1313
PageMetadata,
1414
} from '../contexts/ReverbParamsContext';
1515
import ThemeProvider from './ThemeProvider';
16-
import { PageTypes, Services, Toggles, Variants } from '../models/types/global';
16+
import {
17+
PageTypes,
18+
ServerSideExperiment,
19+
Services,
20+
Toggles,
21+
Variants,
22+
} from '../models/types/global';
1723

1824
jest.mock('./ThemeProvider');
1925

@@ -43,6 +49,7 @@ interface Props extends PropsWithChildren {
4349
pageLang?: string;
4450
isUK?: boolean | null;
4551
idctaConfig?: IdctaConfig | null;
52+
serverSideExperiments?: ServerSideExperiment[] | null;
4653
}
4754

4855
const AllTheProviders: FC<Props> = ({
@@ -66,6 +73,7 @@ const AllTheProviders: FC<Props> = ({
6673
isNextJs = false,
6774
isUK = null,
6875
idctaConfig = null,
76+
serverSideExperiments = null,
6977
}: Props) => {
7078
return (
7179
<ToggleContextProvider toggles={toggles}>
@@ -90,6 +98,7 @@ const AllTheProviders: FC<Props> = ({
9098
showCookieBannerBasedOnCountry={showCookieBannerBasedOnCountry}
9199
statusCode={statusCode}
92100
isUK={isUK}
101+
serverSideExperiments={serverSideExperiments}
93102
>
94103
<AccountProvider initialConfig={idctaConfig}>
95104
<ReverbParamsContextProvider metadata={pageMetadata}>
@@ -135,6 +144,7 @@ const customRender = (
135144
pageLang,
136145
isUK,
137146
idctaConfig,
147+
serverSideExperiments,
138148
} = options || {};
139149

140150
return render(ui, {
@@ -160,6 +170,7 @@ const customRender = (
160170
pageLang={pageLang}
161171
isUK={isUK}
162172
idctaConfig={idctaConfig}
173+
serverSideExperiments={serverSideExperiments}
163174
>
164175
{children}
165176
</AllTheProviders>

src/app/contexts/EventTrackingContext/index.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ describe('Expected use', () => {
5454
const trackingData = JSON.parse(testEl.textContent as string);
5555

5656
expect(trackingData).toEqual({
57+
appName: 'news-pidgin',
5758
campaignID: 'article-sty',
5859
hashedId: null,
5960
isSignedIn: false,
@@ -78,6 +79,7 @@ describe('Expected use', () => {
7879
const trackingData = JSON.parse(testEl.textContent as string);
7980

8081
expect(trackingData).toEqual({
82+
appName: 'news-kyrgyz',
8183
campaignID: 'index-home',
8284
hashedId: null,
8385
isSignedIn: false,

src/app/contexts/EventTrackingContext/index.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext, PropsWithChildren, use, useMemo } from 'react';
22

33
import { AccountContext } from '#contexts/AccountContext';
4+
import { setActivationTrackingData } from '#app/lib/analyticsUtils/activationTrackingData';
45
import { RequestContext } from '../RequestContext';
56
import useToggle from '../../hooks/useToggle';
67
import {
@@ -86,7 +87,11 @@ export const EventTrackingContextProvider = ({
8687
const { pageType, platform, statsDestination } = requestContext;
8788

8889
const serviceContext = use(ServiceContext);
89-
const { atiAnalyticsProducerId, atiAnalyticsProducerName } = serviceContext;
90+
const {
91+
atiAnalyticsAppName,
92+
atiAnalyticsProducerId,
93+
atiAnalyticsProducerName,
94+
} = serviceContext;
9095

9196
const { isSignedIn, hashedUserId } = use(AccountContext);
9297
const { enabled: eventTrackingIsEnabled } = useToggle('eventTracking');
@@ -100,6 +105,7 @@ export const EventTrackingContextProvider = ({
100105
campaignID,
101106
pageIdentifier,
102107
platform,
108+
appName: atiAnalyticsAppName,
103109
producerId: atiAnalyticsProducerId,
104110
producerName: atiAnalyticsProducerName,
105111
statsDestination,
@@ -109,6 +115,7 @@ export const EventTrackingContextProvider = ({
109115
}
110116
return null;
111117
}, [
118+
atiAnalyticsAppName,
112119
atiAnalyticsProducerId,
113120
atiAnalyticsProducerName,
114121
atiData,
@@ -121,6 +128,8 @@ export const EventTrackingContextProvider = ({
121128
]);
122129

123130
if (!eventTrackingIsEnabled || !atiData) {
131+
setActivationTrackingData({ trackingIsEnabled: false });
132+
124133
return (
125134
<EventTrackingContext.Provider value={NO_TRACKING_PROPS}>
126135
{children}
@@ -139,6 +148,20 @@ export const EventTrackingContextProvider = ({
139148
trackingProps.statsDestination,
140149
].every(Boolean);
141150

151+
// Populated synchronously (not in an effect) so it's set before any descendant's
152+
// effects run and potentially trigger an Optimizely decision on this same render pass.
153+
const activationTrackingData = {
154+
trackingIsEnabled: Boolean(hasRequiredProps),
155+
pageIdentifier: trackingProps?.pageIdentifier,
156+
platform: trackingProps?.platform,
157+
appName: trackingProps?.appName,
158+
producerName: trackingProps?.producerName,
159+
statsDestination: trackingProps?.statsDestination,
160+
isSignedIn: trackingProps?.isSignedIn,
161+
hashedId: trackingProps?.hashedId,
162+
};
163+
setActivationTrackingData(activationTrackingData);
164+
142165
return (
143166
<EventTrackingContext.Provider
144167
value={

src/app/hooks/useOptimizelyVariation/activateExperiment/index.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import onClient from '#lib/utilities/onClient';
22
import { ReactSDKClient } from '@optimizely/react-sdk';
3-
import activateExperiment from '.';
3+
import activateExperiment, { resetActivatedExperiments } from '.';
44

55
jest.mock('#lib/utilities/onClient');
66

77
describe('activateExperiment', () => {
88
afterEach(() => {
99
jest.clearAllMocks();
10+
resetActivatedExperiments();
1011
});
1112

1213
const mockOptimizely = {
@@ -52,4 +53,57 @@ describe('activateExperiment', () => {
5253
expect(mockOptimizely.setForcedVariation).not.toHaveBeenCalled();
5354
expect(mockOptimizely.activate).not.toHaveBeenCalled();
5455
});
56+
57+
it('should not activate again if the experiment was already activated', async () => {
58+
(onClient as jest.Mock).mockReturnValue(true);
59+
mockOptimizely.onReady.mockResolvedValue({ success: true });
60+
61+
await activateExperiment({
62+
optimizely: mockOptimizely as unknown as ReactSDKClient,
63+
experimentName: mockExperimentName,
64+
experimentVariation: mockExperimentVariation,
65+
});
66+
67+
await activateExperiment({
68+
optimizely: mockOptimizely as unknown as ReactSDKClient,
69+
experimentName: mockExperimentName,
70+
experimentVariation: mockExperimentVariation,
71+
});
72+
73+
expect(mockOptimizely.activate).toHaveBeenCalledTimes(1);
74+
});
75+
76+
it('should not activate again for a concurrent hook instance activating the same experiment', async () => {
77+
(onClient as jest.Mock).mockReturnValue(true);
78+
mockOptimizely.onReady.mockResolvedValue({ success: true });
79+
80+
await Promise.all([
81+
activateExperiment({
82+
optimizely: mockOptimizely as unknown as ReactSDKClient,
83+
experimentName: mockExperimentName,
84+
experimentVariation: mockExperimentVariation,
85+
}),
86+
activateExperiment({
87+
optimizely: mockOptimizely as unknown as ReactSDKClient,
88+
experimentName: mockExperimentName,
89+
experimentVariation: mockExperimentVariation,
90+
}),
91+
]);
92+
93+
expect(mockOptimizely.activate).toHaveBeenCalledTimes(1);
94+
});
95+
96+
it('should not set a forced variation or activate when onReady resolves with success: false', async () => {
97+
(onClient as jest.Mock).mockReturnValueOnce(true);
98+
mockOptimizely.onReady.mockResolvedValue({ success: false });
99+
100+
await activateExperiment({
101+
optimizely: mockOptimizely as unknown as ReactSDKClient,
102+
experimentName: mockExperimentName,
103+
experimentVariation: mockExperimentVariation,
104+
});
105+
106+
expect(mockOptimizely.setForcedVariation).not.toHaveBeenCalled();
107+
expect(mockOptimizely.activate).not.toHaveBeenCalled();
108+
});
55109
});

src/app/hooks/useOptimizelyVariation/activateExperiment/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import onClient from '#lib/utilities/onClient';
22
import { ReactSDKClient } from '@optimizely/react-sdk';
33

4+
// Module-level (not per hook-instance) so concurrent renders of the same
5+
// experiment can't each independently pass the guard and call activate().
6+
const activatedExperiments = new Set<string>();
7+
8+
const resetActivatedExperiments = () => activatedExperiments.clear();
9+
410
type Props = {
511
optimizely: ReactSDKClient;
612
experimentName: string;
@@ -13,12 +19,14 @@ const activateExperiment = async ({
1319
experimentVariation,
1420
}: Props) => {
1521
if (onClient() && optimizely) {
16-
const success = await optimizely?.onReady();
17-
if (success) {
22+
const { success } = await optimizely.onReady();
23+
if (success && !activatedExperiments.has(experimentName)) {
24+
activatedExperiments.add(experimentName);
1825
optimizely.setForcedVariation(experimentName, experimentVariation);
1926
optimizely.activate(experimentName);
2027
}
2128
}
2229
};
2330

2431
export default activateExperiment;
32+
export { resetActivatedExperiments };

0 commit comments

Comments
 (0)