Skip to content

Commit a4c2e48

Browse files
authored
feat: behavioral targeting for web experiment client (#265)
1 parent 2207fb9 commit a4c2e48

10 files changed

Lines changed: 220 additions & 59 deletions

File tree

packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export class BehavioralTargetingManager {
107107
const eventTypes = new Set<string>();
108108
for (const andGroup of rules) {
109109
for (const conditionSet of andGroup) {
110-
if (conditionSet.condition.type === 'event') {
110+
if (conditionSet.condition.type === 'behavior') {
111111
eventTypes.add(conditionSet.condition.event_type);
112112
}
113113
}

packages/experiment-tag/src/behavioral-targeting/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface BehavioralConditionSet {
2424
* A behavioral condition
2525
*/
2626
export interface BehavioralCondition {
27-
type: 'event';
27+
type: 'behavior';
2828
event_type: string; // Event name
2929
op: (typeof EvaluationOperator)[
3030
| 'GREATER_THAN_EQUALS'
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Check if two Maps of behavioral flags are equal.
3+
* Compares flag keys and their associated behavior ID sets.
4+
* @param current Current map of flag keys to behavior ID sets
5+
* @param previous Previous map of flag keys to behavior ID sets
6+
* @returns true if the maps are equal, false if they're different
7+
*/
8+
export function areBehaviorsEqual(
9+
current: Map<string, Set<string>> | undefined,
10+
previous: Map<string, Set<string>>,
11+
): boolean {
12+
// If one is undefined and the other isn't, they're different
13+
if (!current && previous.size > 0) return false;
14+
if (current && current.size !== previous.size) return false;
15+
if (!current) return true;
16+
17+
// Check if all keys and their values match
18+
for (const [flagKey, behaviorIds] of current) {
19+
const previousBehaviorIds = previous.get(flagKey);
20+
if (!previousBehaviorIds) return false;
21+
22+
// Compare sets of behavior IDs
23+
if (behaviorIds.size !== previousBehaviorIds.size) return false;
24+
for (const id of behaviorIds) {
25+
if (!previousBehaviorIds.has(id)) return false;
26+
}
27+
}
28+
29+
return true;
30+
}

packages/experiment-tag/src/experiment.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as FeatureExperiment from '@amplitude/experiment-js-client';
1818
import mutate, { MutationController } from 'dom-mutator';
1919
import * as domMutatorExports from 'dom-mutator';
2020

21+
import { BehavioralTargetingManager } from './behavioral-targeting';
2122
import { showPreviewModeModal } from './preview/preview';
2223
import { MessageBus } from './subscriptions/message-bus';
2324
import {
@@ -38,6 +39,7 @@ import {
3839
PreviewVariantsOptions,
3940
PreviewState,
4041
RevertVariantsOptions,
42+
BehavioralTargetingRules,
4143
} from './types';
4244
import type { AudienceEvaluationDebugInfo, DebugState } from './types/debug';
4345
import { applyAntiFlickerCss } from './util/anti-flicker';
@@ -113,6 +115,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
113115
private readonly messageBus: MessageBus;
114116
private pageObjects: PageObjects;
115117
private activePages: PageObjects = {};
118+
private readonly behavioralTargetingRules: BehavioralTargetingRules = {};
119+
public readonly behavioralTargetingManager:
120+
| BehavioralTargetingManager
121+
| undefined;
116122
private subscriptionManager: SubscriptionManager | undefined;
117123
private isVisualEditorMode = false;
118124
private isDebugActive = false;
@@ -135,6 +141,17 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
135141
this.apiKey = apiKey;
136142
this.initialFlags = JSON.parse(initConfigs.initialFlags);
137143
this.pageObjects = JSON.parse(initConfigs.pageObjects);
144+
this.behavioralTargetingRules = initConfigs.behavioralTargetingRules
145+
? JSON.parse(initConfigs.behavioralTargetingRules)
146+
: {};
147+
148+
// Initialize behavioral targeting infrastructure only if there are rules
149+
if (Object.keys(this.behavioralTargetingRules).length > 0) {
150+
this.behavioralTargetingManager = new BehavioralTargetingManager(
151+
this.apiKey,
152+
this.behavioralTargetingRules,
153+
);
154+
}
138155
// merge config with defaults and experimentConfig (if provided)
139156
this.config = {
140157
...Defaults,
@@ -214,6 +231,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
214231
this,
215232
this.messageBus,
216233
this.pageObjects,
234+
this.behavioralTargetingManager,
217235
{
218236
...this.config,
219237
isVisualEditorMode: this.isVisualEditorMode,
@@ -332,6 +350,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
332350
this.globalScope.experimentIntegration.type = 'integration';
333351
this.experimentClient.addPlugin(this.globalScope.experimentIntegration);
334352
this.experimentClient.setUser(enrichedUser);
353+
this.updateUserWithBehaviors();
335354

336355
if (!this.isRemoteBlocking) {
337356
// Remove anti-flicker css if remote flags are not blocking
@@ -591,6 +610,35 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
591610
}
592611
}
593612

613+
/**
614+
* Update the user with matched behavioral targeting IDs.
615+
* Sets the user property `behavioral_targeting` to an array of all matched behavior IDs.
616+
*/
617+
public updateUserWithBehaviors(): void {
618+
// Extract all behavior IDs from the map
619+
if (!this.behavioralTargetingManager) {
620+
return;
621+
}
622+
const behaviorIds: string[] = [];
623+
for (const behaviorSet of this.behavioralTargetingManager
624+
.getMatchedBehaviors()
625+
.values()) {
626+
behaviorIds.push(...behaviorSet);
627+
}
628+
629+
// Get the current user from the experiment client
630+
const currentUser = this.experimentClient.getUser();
631+
632+
// Update user with behavioral_targeting property directly on the user object
633+
const updatedUser = {
634+
...currentUser,
635+
behavioral_targeting: behaviorIds,
636+
};
637+
638+
// Set the updated user
639+
this.experimentClient.setUser(updatedUser);
640+
}
641+
594642
/**
595643
* When in visual editor mode, update the current page objects and reinitialize subscriptions and active pages.
596644
*
@@ -723,14 +771,21 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
723771
}
724772

725773
/**
726-
* Track an analytics event that can trigger page objects.
774+
* Track an analytics event that can trigger page objects and behavioral targeting.
727775
* @param event_type The event type/name
728776
* @param event_properties Optional event properties
729777
*/
730778
public trackEvent(
731779
event_type: string,
732780
event_properties?: Record<string, unknown>,
733781
) {
782+
// Store event in behavioral targeting storage
783+
this.behavioralTargetingManager?.trackEvent(
784+
event_type,
785+
event_properties || {},
786+
);
787+
788+
// Publish to message bus for page object triggers
734789
this.messageBus.publish('analytics_event', {
735790
event: event_type,
736791
properties: event_properties || {},

packages/experiment-tag/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,18 @@ export const initialize = (
3939
.then((previewState) => {
4040
const initialFlags = JSON.stringify(previewState.flags);
4141
const pageObjects = JSON.stringify(previewState.pageViewObjects);
42-
startClient(apiKey, { initialFlags, pageObjects }, config);
42+
const behavioralTargetingRules = JSON.stringify(
43+
previewState.behavioralTargetingRules,
44+
);
45+
startClient(
46+
apiKey,
47+
{
48+
initialFlags,
49+
pageObjects,
50+
behavioralTargetingRules,
51+
},
52+
config,
53+
);
4354
})
4455
.catch((error) => {
4556
console.warn('Failed to fetch latest configs for preview:', error);

packages/experiment-tag/src/preview/preview-api.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { EvaluationFlag } from '@amplitude/experiment-core';
22

33
import { version } from '../../package.json';
4-
import { PageObjects } from '../types';
4+
import { BehavioralTargetingRules, PageObjects } from '../types';
55

66
import { HttpClient } from './http';
77

88
export interface PreviewApi {
99
getPreviewFlagsAndPageViewObjects(): Promise<{
1010
flags: EvaluationFlag[];
1111
pageViewObjects: PageObjects;
12+
behavioralTargetingRules?: BehavioralTargetingRules;
1213
}>;
1314
}
1415

@@ -30,6 +31,7 @@ export class SdkPreviewApi implements PreviewApi {
3031
public async getPreviewFlagsAndPageViewObjects(): Promise<{
3132
flags: EvaluationFlag[];
3233
pageViewObjects: PageObjects;
34+
behavioralTargetingRules?: BehavioralTargetingRules;
3335
}> {
3436
const headers: Record<string, string> = {
3537
Authorization: `Api-Key ${this.deploymentKey}`,
@@ -45,10 +47,12 @@ export class SdkPreviewApi implements PreviewApi {
4547
if (response.status != 200) {
4648
throw Error(`Preview error response: status=${response.status}`);
4749
}
48-
const flags: EvaluationFlag[] = JSON.parse(response.body)
49-
.flags as EvaluationFlag[];
50-
const pageViewObjects: PageObjects = JSON.parse(response.body)
51-
.pageObjects as PageObjects;
52-
return { flags, pageViewObjects };
50+
const responseBody = JSON.parse(response.body);
51+
const flags: EvaluationFlag[] = responseBody.flags as EvaluationFlag[];
52+
const pageViewObjects: PageObjects =
53+
responseBody.pageObjects as PageObjects;
54+
const behavioralTargetingRules: BehavioralTargetingRules | undefined =
55+
responseBody.behavioralTargetingRules;
56+
return { flags, pageViewObjects, behavioralTargetingRules };
5357
}
5458
}

packages/experiment-tag/src/subscriptions/subscriptions.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { EvaluationEngine } from '@amplitude/experiment-core';
22

3+
import { BehavioralTargetingManager } from '../behavioral-targeting';
4+
import { areBehaviorsEqual } from '../behavioral-targeting/util';
35
import { DefaultWebExperimentClient, INJECT_ACTION } from '../experiment';
46
import {
57
ElementAppearedTriggerValue,
@@ -55,11 +57,16 @@ export class SubscriptionManager {
5557
private webExperimentClient: DefaultWebExperimentClient;
5658
private messageBus: MessageBus;
5759
private pageObjects: PageObjects;
60+
private readonly behavioralTargetingManager:
61+
| BehavioralTargetingManager
62+
| undefined;
5863
private options: initOptions;
5964
private readonly globalScope: typeof globalThis;
6065
private pageChangeSubscribers: Set<(event: PageChangeEvent) => void> =
6166
new Set();
6267
private lastNotifiedActivePages: PageObjects = {};
68+
private lastNotifiedActiveBehavioralFlags: Map<string, Set<string>> =
69+
new Map();
6370
private intersectionObservers: Map<string, IntersectionObserver> = new Map();
6471
private elementVisibilityState: Map<string, boolean> = new Map();
6572
private elementAppearedState: Set<string> = new Set();
@@ -99,12 +106,14 @@ export class SubscriptionManager {
99106
webExperimentClient: DefaultWebExperimentClient,
100107
messageBus: MessageBus,
101108
pageObjects: PageObjects,
109+
behavioralTargetingManager: BehavioralTargetingManager | undefined,
102110
options: initOptions,
103111
globalScope: typeof globalThis,
104112
) {
105113
this.webExperimentClient = webExperimentClient;
106114
this.messageBus = messageBus;
107115
this.pageObjects = pageObjects;
116+
this.behavioralTargetingManager = behavioralTargetingManager;
108117
this.options = options;
109118
this.globalScope = globalScope;
110119
}
@@ -325,6 +334,12 @@ export class SubscriptionManager {
325334
url_change: new Set(),
326335
};
327336

337+
// Ensure analytics_event subscription is set up if behavioral targeting exists
338+
// This ensures behavioral targeting evaluation runs even without analytics_event page objects
339+
if (this.behavioralTargetingManager) {
340+
triggerTypeExperimentMap.analytics_event = new Set();
341+
}
342+
328343
for (const [experiment, pages] of Object.entries(this.pageObjects)) {
329344
for (const page of Object.values(pages)) {
330345
if (!triggerTypeExperimentMap[page.trigger_type]) {
@@ -361,6 +376,7 @@ export class SubscriptionManager {
361376
for (const triggerType of Object.keys(triggerTypeExperimentMap)) {
362377
this.messageBus.groupSubscribe(triggerType as MessageType, (payload) => {
363378
const isUrlChange = triggerType === 'url_change';
379+
const isAnalyticsEvent = triggerType === 'analytics_event';
364380

365381
// Handle URL change: reset state and revert injections
366382
if (isUrlChange) {
@@ -379,19 +395,52 @@ export class SubscriptionManager {
379395
this.lastNotifiedActivePages,
380396
);
381397

398+
// Get current behavioral state and check if it changed
399+
const activeBehavioralFlags =
400+
this.webExperimentClient.behavioralTargetingManager?.getMatchedBehaviors();
401+
402+
const behaviorsChanged =
403+
isAnalyticsEvent &&
404+
!areBehaviorsEqual(
405+
activeBehavioralFlags,
406+
this.lastNotifiedActiveBehavioralFlags,
407+
);
408+
if (behaviorsChanged) {
409+
this.webExperimentClient.updateUserWithBehaviors();
410+
}
382411
// Skip processing in visual editor mode or internal updates
383412
const isInternalUpdate =
384413
'updateActivePages' in payload && payload.updateActivePages;
385414
const shouldApplyVariants =
386415
!isInternalUpdate &&
387416
!this.options.isVisualEditorMode &&
388-
(pagesChanged || isUrlChange);
417+
(pagesChanged || behaviorsChanged || isUrlChange);
389418

390419
if (shouldApplyVariants) {
391420
// Determine which experiments to apply variants for
392-
const relevantFlags = isUrlChange
393-
? undefined // All experiments
394-
: Array.from(triggerTypeExperimentMap[triggerType] || []);
421+
let relevantFlags: string[] | undefined;
422+
423+
if (isUrlChange) {
424+
relevantFlags = undefined; // All experiments
425+
} else {
426+
// Combine flags from both page triggers and behavioral changes
427+
const pageTriggerFlags = Array.from(
428+
triggerTypeExperimentMap[triggerType] || [],
429+
);
430+
const behaviorFlags = behaviorsChanged
431+
? Array.from(
432+
new Set([
433+
...Array.from(activeBehavioralFlags?.keys() || []),
434+
...Array.from(
435+
this.lastNotifiedActiveBehavioralFlags?.keys() || [],
436+
),
437+
]),
438+
)
439+
: [];
440+
relevantFlags = Array.from(
441+
new Set([...pageTriggerFlags, ...behaviorFlags]),
442+
);
443+
}
395444

396445
// Apply non-preview variants
397446
this.webExperimentClient.applyVariants({
@@ -427,6 +476,16 @@ export class SubscriptionManager {
427476
if (pagesChanged || isUrlChange) {
428477
this.scheduleDebugNotification();
429478
}
479+
480+
// Update last notified behaviors if they changed
481+
if (behaviorsChanged && activeBehavioralFlags) {
482+
this.lastNotifiedActiveBehavioralFlags = new Map(
483+
Array.from(activeBehavioralFlags.entries()).map(([key, value]) => [
484+
key,
485+
new Set(value),
486+
]),
487+
);
488+
}
430489
});
431490
}
432491
};
@@ -451,6 +510,7 @@ export class SubscriptionManager {
451510
this.maxScrollPercentage = 0;
452511
this.pageLoadTime = Date.now();
453512
this.analyticsEventState.clear();
513+
this.lastNotifiedActiveBehavioralFlags = new Map();
454514

455515
// Clear pending click timeouts
456516
for (const timeout of this.clickTimeouts.values()) {

packages/experiment-tag/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,5 @@ export type WebExperimentUser = {
159159
export type InitConfigs = {
160160
initialFlags: string;
161161
pageObjects: string;
162+
behavioralTargetingRules?: string;
162163
};

0 commit comments

Comments
 (0)