Skip to content

Commit 512c6a6

Browse files
committed
feat: unset user properties when variant evaluates to none
1 parent ce56fb9 commit 512c6a6

File tree

8 files changed

+232
-104
lines changed

8 files changed

+232
-104
lines changed

packages/browser-demo/src/experiment.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const config = {
2121
payload: {},
2222
}
2323
},
24+
analyticsProvider: {
25+
track: (event) => { console.log('Tracked: ' + JSON.stringify(event))},
26+
unset: (event) => { console.log('Unset: ' + JSON.stringify(event))}
27+
}
2428
}
2529

2630
const experiment = Experiment.initialize(

packages/browser/src/experimentClient.ts

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import { version as PACKAGE_VERSION } from '../package.json';
88
import { ExperimentConfig, Defaults, Source } from './config';
99
import { LocalStorage } from './storage/localStorage';
1010
import { FetchHttpClient } from './transport/http';
11-
import { ExposureEvent } from './types/analytics';
11+
import { exposureEvent, isFallback, VariantSource } from './types/analytics';
1212
import { Client } from './types/client';
1313
import { ExperimentUserProvider } from './types/provider';
1414
import { Storage } from './types/storage';
1515
import { HttpClient } from './types/transport';
1616
import { ExperimentUser } from './types/user';
1717
import { Variant, Variants } from './types/variant';
18+
import { isNullOrUndefined } from './util';
1819
import { Backoff } from './util/backoff';
1920
import { urlSafeBase64Encode } from './util/base64';
2021
import { randomString } from './util/randomstring';
@@ -114,37 +115,122 @@ export class ExperimentClient implements Client {
114115
* Access the variant from {@link Source}, falling back on the given
115116
* fallback, then the configured fallbackVariant.
116117
*
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.
118+
* If an {@link ExperimentAnalyticsProvider} is configured and trackExposure is
119+
* true, this function will call the provider with an {@link ExposureEvent}.
120+
* The exposure event does not count towards your event volume within Amplitude.
120121
*
121122
* @param key The key to get the variant for.
122123
* @param fallback The highest priority fallback.
124+
* @param trackExposure Sends a track event via the configured {@link ExperimentAnalyticsProvider}
123125
* @see ExperimentConfig
124126
* @see ExperimentAnalyticsProvider
125127
*/
126-
public variant(key: string, fallback?: string | Variant): Variant {
128+
public variant(
129+
key: string,
130+
fallback?: string | Variant,
131+
trackExposure = true,
132+
): Variant {
127133
if (!this.apiKey) {
128134
return { value: undefined };
129135
}
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-
),
138-
);
136+
const { source, variant } = this.variantAndSource(key, fallback);
137+
138+
if (trackExposure) {
139+
if (isFallback(source) || !variant?.value) {
140+
// fallbacks indicate not being allocated into an experiment, so
141+
// we can unset the property
142+
this.config.analyticsProvider?.unset(
143+
exposureEvent(this.addContext(this.getUser()), key, variant, source),
144+
);
145+
} else {
146+
if (variant?.value) {
147+
// only track when there's a value for a non fallback variant
148+
this.config.analyticsProvider?.track(
149+
exposureEvent(
150+
this.addContext(this.getUser()),
151+
key,
152+
variant,
153+
source,
154+
),
155+
);
156+
}
157+
}
158+
}
159+
160+
this.debug(`[Experiment] variant for ${key} is ${variant.value}`);
161+
return variant;
162+
}
163+
164+
private variantAndSource(
165+
key: string,
166+
fallback: string | Variant,
167+
): {
168+
variant: Variant;
169+
source: VariantSource;
170+
} {
171+
if (this.config.source === Source.InitialVariants) {
172+
// for source = InitialVariants, fallback order goes:
173+
// 1. InitialFlags
174+
// 2. Local Storage
175+
// 3. Function fallback
176+
// 4. Config fallback
177+
178+
const sourceVariant = this.sourceVariants()[key];
179+
if (!isNullOrUndefined(sourceVariant)) {
180+
return {
181+
variant: this.convertVariant(sourceVariant),
182+
source: VariantSource.INITIAL_VARIANTS,
183+
};
184+
}
185+
const secondaryVariant = this.secondaryVariants()[key];
186+
if (!isNullOrUndefined(secondaryVariant)) {
187+
return {
188+
variant: this.convertVariant(secondaryVariant),
189+
source: VariantSource.SECONDARY_LOCAL_STORAGE,
190+
};
191+
}
192+
if (!isNullOrUndefined(fallback)) {
193+
return {
194+
variant: this.convertVariant(fallback),
195+
source: VariantSource.FALLBACK_INLINE,
196+
};
197+
}
198+
return {
199+
variant: this.convertVariant(this.config.fallbackVariant),
200+
source: VariantSource.FALLBACK_CONFIG,
201+
};
202+
} else {
203+
// for source = LocalStorage, fallback order goes:
204+
// 1. Local Storage
205+
// 2. Function fallback
206+
// 3. InitialFlags
207+
// 4. Config fallback
208+
209+
const sourceVariant = this.sourceVariants()[key];
210+
if (!isNullOrUndefined(sourceVariant)) {
211+
return {
212+
variant: this.convertVariant(sourceVariant),
213+
source: VariantSource.LOCAL_STORAGE,
214+
};
215+
}
216+
if (!isNullOrUndefined(fallback)) {
217+
return {
218+
variant: this.convertVariant(fallback),
219+
source: VariantSource.FALLBACK_INLINE,
220+
};
221+
}
222+
const secondaryVariant = this.secondaryVariants()[key];
223+
if (!isNullOrUndefined(secondaryVariant)) {
224+
return {
225+
variant: this.convertVariant(secondaryVariant),
226+
source: VariantSource.SECONDARY_INITIAL_VARIANTS,
227+
};
228+
}
229+
return {
230+
variant: this.convertVariant(this.config.fallbackVariant),
231+
source: VariantSource.FALLBACK_CONFIG,
232+
};
139233
}
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;
148234
}
149235

150236
/**

packages/browser/src/integration/amplitude.ts

Lines changed: 23 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,17 @@ export class AmplitudeAnalyticsProvider implements ExperimentAnalyticsProvider {
7890
}
7991

8092
track(event: ExperimentAnalyticsEvent): void {
81-
if (event.userProperties) {
82-
this.amplitudeInstance.setUserProperties(event.userProperties);
83-
}
93+
// if the variant has a value, set the user property and log an event
94+
this.amplitudeInstance.setUserProperties({
95+
[event.userProperty]: event.variant?.value,
96+
});
8497
this.amplitudeInstance.logEvent(event.name, event.properties);
8598
}
99+
100+
unset(event: ExperimentAnalyticsEvent): void {
101+
// if the variant does not have a value, unset the user property
102+
this.amplitudeInstance.identify(
103+
new safeGlobal.amplitude.Identify().unset(event.userProperty),
104+
);
105+
}
86106
}

packages/browser/src/types/analytics.ts

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,41 +26,70 @@ export interface ExperimentAnalyticsEvent {
2626
* User properties to identify with the user prior to sending the event.
2727
*/
2828
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>;
3929

4030
/**
4131
* The user exposed to the flag/experiment variant.
4232
*/
43-
public user: ExperimentUser;
33+
user: ExperimentUser;
4434

4535
/**
4636
* The key of the flag/experiment that the user has been exposed to.
4737
*/
48-
public key: string;
38+
key: string;
4939

5040
/**
5141
* The variant of the flag/experiment that the user has been exposed to.
5242
*/
53-
public variant: Variant;
43+
variant: Variant;
44+
45+
/**
46+
* The user property for the flag/experiment (auto-generated from the key)
47+
*/
48+
userProperty: string;
49+
}
5450

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-
}
51+
/**
52+
* Event for tracking a user's exposure to a variant. This event will not count
53+
* towards your analytics event volume.
54+
*/
55+
export const exposureEvent = (
56+
user: ExperimentUser,
57+
key: string,
58+
variant: Variant,
59+
source: VariantSource,
60+
): ExperimentAnalyticsEvent => {
61+
const name = '[Experiment] Exposure';
62+
const value = variant?.value;
63+
const userProperty = `[Experiment] ${key}`;
64+
return {
65+
name,
66+
user,
67+
key,
68+
variant,
69+
userProperty,
70+
properties: {
71+
key,
72+
variant: value,
73+
source,
74+
},
75+
userProperties: {
76+
[userProperty]: value,
77+
},
78+
};
79+
};
80+
81+
export enum VariantSource {
82+
LOCAL_STORAGE = 'storage',
83+
INITIAL_VARIANTS = 'initial',
84+
SECONDARY_LOCAL_STORAGE = 'secondary-storage',
85+
SECONDARY_INITIAL_VARIANTS = 'secondary-initial',
86+
FALLBACK_INLINE = 'fallback-inline',
87+
FALLBACK_CONFIG = 'fallback-config',
6688
}
89+
90+
export const isFallback = (source: VariantSource): boolean => {
91+
return (
92+
source === VariantSource.FALLBACK_INLINE ||
93+
source === VariantSource.FALLBACK_CONFIG
94+
);
95+
};

packages/browser/src/types/client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { Variant, Variants } from './variant';
88
*/
99
export interface Client {
1010
fetch(user?: ExperimentUser): Promise<Client>;
11-
variant(key: string, fallback?: string | Variant): Variant;
11+
variant(
12+
key: string,
13+
fallback?: string | Variant,
14+
trackExposure?: boolean,
15+
): Variant;
1216
all(): Variants;
1317
getUser(): ExperimentUser;
1418
setUser(user: ExperimentUser): void;

packages/browser/src/types/provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export interface ExperimentUserProvider {
1818
*/
1919
export interface ExperimentAnalyticsProvider {
2020
track(event: ExperimentAnalyticsEvent): void;
21+
unset(event: ExperimentAnalyticsEvent): void;
2122
}

packages/browser/src/util/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const isNullOrUndefined = (value: unknown): boolean => {
2+
return value === null || value === undefined;
3+
};

0 commit comments

Comments
 (0)