Skip to content

Commit dbab7e8

Browse files
authored
feat: unset user properties when variant evaluates to none or is a fallback (#13)
* feat: unset user properties when variant evaluates to none * feat: unset user properties when variant evaluates to none or is a fallback * feat: unset user properties when variant evaluates to none or is a fallback * feat: unset user properties when variant evaluates to none or is a fallback * feat: unset user properties when variant evaluates to none or is a fallback * Fix lint issue * fix typing
1 parent ce56fb9 commit dbab7e8

File tree

10 files changed

+309
-127
lines changed

10 files changed

+309
-127
lines changed

packages/browser-demo/src/experiment.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const config = {
2121
payload: {},
2222
}
2323
},
24+
analyticsProvider: {
25+
track: (event) => { console.log('Tracked: ' + JSON.stringify(event))},
26+
setUserProperty: (event) => { console.log('Set User Property: ' + JSON.stringify(event))},
27+
unsetUserProperty: (event) => { console.log('Unset User Property: ' + JSON.stringify(event))}
28+
}
2429
}
2530

2631
const experiment = Experiment.initialize(

packages/browser/src/config.ts

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,9 @@ import {
22
ExperimentAnalyticsProvider,
33
ExperimentUserProvider,
44
} from './types/provider';
5+
import { Source } from './types/source';
56
import { Variant, Variants } from './types/variant';
67

7-
/**
8-
* Determines the primary source of variants before falling back.
9-
*
10-
* @category Configuration
11-
*/
12-
export enum Source {
13-
/**
14-
* The default way to source variants within your application. Before the
15-
* assignments are fetched, `getVariant(s)` will fallback to local storage
16-
* first, then `initialVariants` if local storage is empty. This option
17-
* effectively falls back to an assignment fetched previously.
18-
*/
19-
LocalStorage = 'localStorage',
20-
21-
/**
22-
* This bootstrap option is used primarily for servers-side rendering using an
23-
* Experiment server SDK. This bootstrap option always prefers the config
24-
* `initialVariants` over data in local storage, even if variants are fetched
25-
* successfully and stored locally.
26-
*/
27-
InitialVariants = 'initialVariants',
28-
}
29-
308
/**
319
* @category Configuration
3210
*/

packages/browser/src/experimentClient.ts

Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@
55

66
import { version as PACKAGE_VERSION } from '../package.json';
77

8-
import { ExperimentConfig, Defaults, Source } from './config';
8+
import { ExperimentConfig, Defaults } from './config';
99
import { LocalStorage } from './storage/localStorage';
1010
import { FetchHttpClient } from './transport/http';
11-
import { ExposureEvent } from './types/analytics';
11+
import { exposureEvent } from './types/analytics';
1212
import { Client } from './types/client';
1313
import { ExperimentUserProvider } from './types/provider';
14+
import { isFallback, Source, VariantSource } from './types/source';
1415
import { Storage } from './types/storage';
1516
import { HttpClient } from './types/transport';
1617
import { ExperimentUser } from './types/user';
1718
import { Variant, Variants } from './types/variant';
19+
import { isNullOrUndefined } from './util';
1820
import { Backoff } from './util/backoff';
1921
import { urlSafeBase64Encode } from './util/base64';
2022
import { randomString } from './util/randomstring';
@@ -114,9 +116,9 @@ export class ExperimentClient implements Client {
114116
* Access the variant from {@link Source}, falling back on the given
115117
* fallback, then the configured fallbackVariant.
116118
*
117-
* If an {@link ExperimentAnalyticsProvider} is configured, this function will
118-
* call the provider with an {@link ExposureEvent}. The exposure event does
119-
* not count towards your event volume within Amplitude.
119+
* If an {@link ExperimentAnalyticsProvider} is configured and trackExposure is
120+
* true, this function will call the provider with an {@link ExposureEvent}.
121+
* The exposure event does not count towards your event volume within Amplitude.
120122
*
121123
* @param key The key to get the variant for.
122124
* @param fallback The highest priority fallback.
@@ -127,24 +129,100 @@ export class ExperimentClient implements Client {
127129
if (!this.apiKey) {
128130
return { value: undefined };
129131
}
130-
const sourceVariant = this.sourceVariants()[key];
131-
if (sourceVariant?.value) {
132-
this.config.analyticsProvider?.track(
133-
new ExposureEvent(
134-
this.addContext(this.getUser()),
135-
key,
136-
this.convertVariant(sourceVariant),
137-
),
132+
const { source, variant } = this.variantAndSource(key, fallback);
133+
134+
if (isFallback(source) || !variant?.value) {
135+
// fallbacks indicate not being allocated into an experiment, so
136+
// we can unset the property
137+
this.config.analyticsProvider?.unsetUserProperty?.(
138+
exposureEvent(this.addContext(this.getUser()), key, variant, source),
139+
);
140+
} else if (variant?.value) {
141+
// only track when there's a value for a non fallback variant
142+
const event = exposureEvent(
143+
this.addContext(this.getUser()),
144+
key,
145+
variant,
146+
source,
138147
);
148+
this.config.analyticsProvider?.setUserProperty?.(event);
149+
this.config.analyticsProvider?.track(event);
150+
}
151+
152+
this.debug(`[Experiment] variant for ${key} is ${variant.value}`);
153+
return variant;
154+
}
155+
156+
private variantAndSource(
157+
key: string,
158+
fallback: string | Variant,
159+
): {
160+
variant: Variant;
161+
source: VariantSource;
162+
} {
163+
if (this.config.source === Source.InitialVariants) {
164+
// for source = InitialVariants, fallback order goes:
165+
// 1. InitialFlags
166+
// 2. Local Storage
167+
// 3. Function fallback
168+
// 4. Config fallback
169+
170+
const sourceVariant = this.sourceVariants()[key];
171+
if (!isNullOrUndefined(sourceVariant)) {
172+
return {
173+
variant: this.convertVariant(sourceVariant),
174+
source: VariantSource.InitialVariants,
175+
};
176+
}
177+
const secondaryVariant = this.secondaryVariants()[key];
178+
if (!isNullOrUndefined(secondaryVariant)) {
179+
return {
180+
variant: this.convertVariant(secondaryVariant),
181+
source: VariantSource.SecondaryLocalStoraage,
182+
};
183+
}
184+
if (!isNullOrUndefined(fallback)) {
185+
return {
186+
variant: this.convertVariant(fallback),
187+
source: VariantSource.FallbackInline,
188+
};
189+
}
190+
return {
191+
variant: this.convertVariant(this.config.fallbackVariant),
192+
source: VariantSource.FallbackConfig,
193+
};
194+
} else {
195+
// for source = LocalStorage, fallback order goes:
196+
// 1. Local Storage
197+
// 2. Function fallback
198+
// 3. InitialFlags
199+
// 4. Config fallback
200+
201+
const sourceVariant = this.sourceVariants()[key];
202+
if (!isNullOrUndefined(sourceVariant)) {
203+
return {
204+
variant: this.convertVariant(sourceVariant),
205+
source: VariantSource.LocalStorage,
206+
};
207+
}
208+
if (!isNullOrUndefined(fallback)) {
209+
return {
210+
variant: this.convertVariant(fallback),
211+
source: VariantSource.FallbackInline,
212+
};
213+
}
214+
const secondaryVariant = this.secondaryVariants()[key];
215+
if (!isNullOrUndefined(secondaryVariant)) {
216+
return {
217+
variant: this.convertVariant(secondaryVariant),
218+
source: VariantSource.SecondaryInitialVariants,
219+
};
220+
}
221+
return {
222+
variant: this.convertVariant(this.config.fallbackVariant),
223+
source: VariantSource.FallbackConfig,
224+
};
139225
}
140-
const variant =
141-
sourceVariant ??
142-
fallback ??
143-
this.secondaryVariants()[key] ??
144-
this.config.fallbackVariant;
145-
const converted = this.convertVariant(variant);
146-
this.debug(`[Experiment] variant for ${key} is ${converted.value}`);
147-
return converted;
148226
}
149227

150228
/**

packages/browser/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @module experiment-js-client
66
*/
77

8-
export { ExperimentConfig, Source } from './config';
8+
export { ExperimentConfig } from './config';
99
export {
1010
AmplitudeUserProvider,
1111
AmplitudeAnalyticsProvider,
@@ -18,5 +18,6 @@ export {
1818
ExperimentUserProvider,
1919
ExperimentAnalyticsProvider,
2020
} from './types/provider';
21+
export { Source } from './types/source';
2122
export { ExperimentUser } from './types/user';
2223
export { Variant, Variants } from './types/variant';

packages/browser/src/integration/amplitude.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@ import {
44
ExperimentAnalyticsProvider,
55
} from '../types/provider';
66
import { ExperimentUser } from '../types/user';
7+
import { safeGlobal } from '../util/global';
8+
9+
declare global {
10+
// eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any
11+
var amplitude: any;
12+
}
13+
14+
type AmplitudeIdentify = {
15+
set(property: string, value: unknown): void;
16+
unset(property: string): void;
17+
};
718

819
type AmplitudeInstance = {
920
options?: AmplitudeOptions;
1021
_ua?: AmplitudeUAParser;
1122
logEvent(eventName: string, properties: Record<string, string>): void;
1223
setUserProperties(userProperties: Record<string, unknown>): void;
24+
identify(identify: AmplitudeIdentify): void;
1325
};
1426

1527
type AmplitudeOptions = {
@@ -78,9 +90,20 @@ export class AmplitudeAnalyticsProvider implements ExperimentAnalyticsProvider {
7890
}
7991

8092
track(event: ExperimentAnalyticsEvent): void {
81-
if (event.userProperties) {
82-
this.amplitudeInstance.setUserProperties(event.userProperties);
83-
}
8493
this.amplitudeInstance.logEvent(event.name, event.properties);
8594
}
95+
96+
setUserProperty(event: ExperimentAnalyticsEvent): void {
97+
// if the variant has a value, set the user property and log an event
98+
this.amplitudeInstance.setUserProperties({
99+
[event.userProperty]: event.variant?.value,
100+
});
101+
}
102+
103+
unsetUserProperty(event: ExperimentAnalyticsEvent): void {
104+
// if the variant does not have a value, unset the user property
105+
this.amplitudeInstance.identify(
106+
new safeGlobal.amplitude.Identify().unset(event.userProperty),
107+
);
108+
}
86109
}
Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { VariantSource } from './source';
12
import { ExperimentUser } from './user';
23
import { Variant } from './variant';
34

@@ -19,48 +20,74 @@ export interface ExperimentAnalyticsEvent {
1920
* Event properties for the analytics event. Should be passed as the event
2021
* properties to the analytics implementation provided by the
2122
* {@link ExperimentAnalyticsProvider}.
23+
* This is equivalent to
24+
* ```
25+
* {
26+
* "key": key,
27+
* "variant": variant,
28+
* }
29+
* ```
2230
*/
2331
properties: Record<string, string>;
2432

2533
/**
2634
* User properties to identify with the user prior to sending the event.
35+
* This is equivalent to
36+
* ```
37+
* {
38+
* [userProperty]: variant
39+
* }
40+
* ```
2741
*/
2842
userProperties?: Record<string, unknown>;
29-
}
30-
31-
/**
32-
* Event for tracking a user's exposure to a variant. This event will not count
33-
* towards your analytics event volume.
34-
*/
35-
export class ExposureEvent implements ExperimentAnalyticsEvent {
36-
name = '[Experiment] Exposure';
37-
properties: Record<string, string>;
38-
userProperties?: Record<string, unknown>;
3943

4044
/**
4145
* The user exposed to the flag/experiment variant.
4246
*/
43-
public user: ExperimentUser;
47+
user: ExperimentUser;
4448

4549
/**
4650
* The key of the flag/experiment that the user has been exposed to.
4751
*/
48-
public key: string;
52+
key: string;
4953

5054
/**
5155
* The variant of the flag/experiment that the user has been exposed to.
5256
*/
53-
public variant: Variant;
57+
variant: Variant;
5458

55-
public constructor(user: ExperimentUser, key: string, variant: Variant) {
56-
this.key = key;
57-
this.variant = variant;
58-
this.properties = {
59-
key: key,
60-
variant: variant.value,
61-
};
62-
this.userProperties = {
63-
[`[Experiment] ${key}`]: variant.value,
64-
};
65-
}
59+
/**
60+
* The user property for the flag/experiment (auto-generated from the key)
61+
*/
62+
userProperty: string;
6663
}
64+
65+
/**
66+
* Event for tracking a user's exposure to a variant. This event will not count
67+
* towards your analytics event volume.
68+
*/
69+
export const exposureEvent = (
70+
user: ExperimentUser,
71+
key: string,
72+
variant: Variant,
73+
source: VariantSource,
74+
): ExperimentAnalyticsEvent => {
75+
const name = '[Experiment] Exposure';
76+
const value = variant?.value;
77+
const userProperty = `[Experiment] ${key}`;
78+
return {
79+
name,
80+
user,
81+
key,
82+
variant,
83+
userProperty,
84+
properties: {
85+
key,
86+
variant: value,
87+
source,
88+
},
89+
userProperties: {
90+
[userProperty]: value,
91+
},
92+
};
93+
};

packages/browser/src/types/provider.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,27 @@ export interface ExperimentUserProvider {
1717
* @category Provider
1818
*/
1919
export interface ExperimentAnalyticsProvider {
20+
/**
21+
* Wraps an analytics event track call. This is typically called by the
22+
* experiment client after setting user properties to track an
23+
* "[Experiment] Exposure" event
24+
* @param event see {@link ExperimentAnalyticsEvent}
25+
*/
2026
track(event: ExperimentAnalyticsEvent): void;
27+
28+
/**
29+
* Wraps an analytics identify or set user property call. This is typically
30+
* called by the experiment client before sending an
31+
* "[Experiment] Exposure" event.
32+
* @param event see {@link ExperimentAnalyticsEvent}
33+
*/
34+
setUserProperty?(event: ExperimentAnalyticsEvent): void;
35+
36+
/**
37+
* Wraps an analytics unset user property call. This is typically
38+
* called by the experiment client when a user has been evaluated to use
39+
* a fallback variant.
40+
* @param event see {@link ExperimentAnalyticsEvent}
41+
*/
42+
unsetUserProperty?(event: ExperimentAnalyticsEvent): void;
2143
}

0 commit comments

Comments
 (0)