Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/browser-demo/src/experiment.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const config = {
payload: {},
}
},
analyticsProvider: {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be updated

track: (event) => { console.log('Tracked: ' + JSON.stringify(event))},
setUserProperty: (event) => { console.log('Set User Property: ' + JSON.stringify(event))},
unsetUserProperty: (event) => { console.log('Unset User Property: ' + JSON.stringify(event))}
}
}

const experiment = Experiment.initialize(
Expand Down
24 changes: 1 addition & 23 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,9 @@ import {
ExperimentAnalyticsProvider,
ExperimentUserProvider,
} from './types/provider';
import { Source } from './types/source';
import { Variant, Variants } from './types/variant';

/**
* Determines the primary source of variants before falling back.
*
* @category Configuration
*/
export enum Source {
/**
* The default way to source variants within your application. Before the
* assignments are fetched, `getVariant(s)` will fallback to local storage
* first, then `initialVariants` if local storage is empty. This option
* effectively falls back to an assignment fetched previously.
*/
LocalStorage = 'localStorage',

/**
* This bootstrap option is used primarily for servers-side rendering using an
* Experiment server SDK. This bootstrap option always prefers the config
* `initialVariants` over data in local storage, even if variants are fetched
* successfully and stored locally.
*/
InitialVariants = 'initialVariants',
}

/**
* @category Configuration
*/
Expand Down
120 changes: 99 additions & 21 deletions packages/browser/src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@

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

import { ExperimentConfig, Defaults, Source } from './config';
import { ExperimentConfig, Defaults } from './config';
import { LocalStorage } from './storage/localStorage';
import { FetchHttpClient } from './transport/http';
import { ExposureEvent } from './types/analytics';
import { exposureEvent } from './types/analytics';
import { Client } from './types/client';
import { ExperimentUserProvider } from './types/provider';
import { isFallback, Source, VariantSource } from './types/source';
import { Storage } from './types/storage';
import { HttpClient } from './types/transport';
import { ExperimentUser } from './types/user';
import { Variant, Variants } from './types/variant';
import { isNullOrUndefined } from './util';
import { Backoff } from './util/backoff';
import { urlSafeBase64Encode } from './util/base64';
import { randomString } from './util/randomstring';
Expand Down Expand Up @@ -114,9 +116,9 @@ export class ExperimentClient implements Client {
* Access the variant from {@link Source}, falling back on the given
* fallback, then the configured fallbackVariant.
*
* If an {@link ExperimentAnalyticsProvider} is configured, this function will
* call the provider with an {@link ExposureEvent}. The exposure event does
* not count towards your event volume within Amplitude.
* If an {@link ExperimentAnalyticsProvider} is configured and trackExposure is
* true, this function will call the provider with an {@link ExposureEvent}.
* The exposure event does not count towards your event volume within Amplitude.
*
* @param key The key to get the variant for.
* @param fallback The highest priority fallback.
Expand All @@ -127,24 +129,100 @@ export class ExperimentClient implements Client {
if (!this.apiKey) {
return { value: undefined };
}
const sourceVariant = this.sourceVariants()[key];
if (sourceVariant?.value) {
this.config.analyticsProvider?.track(
new ExposureEvent(
this.addContext(this.getUser()),
key,
this.convertVariant(sourceVariant),
),
const { source, variant } = this.variantAndSource(key, fallback);

if (isFallback(source) || !variant?.value) {
// fallbacks indicate not being allocated into an experiment, so
// we can unset the property
this.config.analyticsProvider?.unsetUserProperty?.(
exposureEvent(this.addContext(this.getUser()), key, variant, source),
);
} else if (variant?.value) {
// only track when there's a value for a non fallback variant
const event = exposureEvent(
this.addContext(this.getUser()),
key,
variant,
source,
);
this.config.analyticsProvider?.setUserProperty?.(event);
this.config.analyticsProvider?.track(event);
}

this.debug(`[Experiment] variant for ${key} is ${variant.value}`);
return variant;
}

private variantAndSource(
key: string,
fallback: string | Variant,
): {
variant: Variant;
source: VariantSource;
} {
if (this.config.source === Source.InitialVariants) {
// for source = InitialVariants, fallback order goes:
// 1. InitialFlags
// 2. Local Storage
// 3. Function fallback
// 4. Config fallback

const sourceVariant = this.sourceVariants()[key];
if (!isNullOrUndefined(sourceVariant)) {
return {
variant: this.convertVariant(sourceVariant),
source: VariantSource.InitialVariants,
};
}
const secondaryVariant = this.secondaryVariants()[key];
if (!isNullOrUndefined(secondaryVariant)) {
return {
variant: this.convertVariant(secondaryVariant),
source: VariantSource.SecondaryLocalStoraage,
};
}
if (!isNullOrUndefined(fallback)) {
return {
variant: this.convertVariant(fallback),
source: VariantSource.FallbackInline,
};
}
return {
variant: this.convertVariant(this.config.fallbackVariant),
source: VariantSource.FallbackConfig,
};
} else {
// for source = LocalStorage, fallback order goes:
// 1. Local Storage
// 2. Function fallback
// 3. InitialFlags
// 4. Config fallback

const sourceVariant = this.sourceVariants()[key];
if (!isNullOrUndefined(sourceVariant)) {
return {
variant: this.convertVariant(sourceVariant),
source: VariantSource.LocalStorage,
};
}
if (!isNullOrUndefined(fallback)) {
return {
variant: this.convertVariant(fallback),
source: VariantSource.FallbackInline,
};
}
const secondaryVariant = this.secondaryVariants()[key];
if (!isNullOrUndefined(secondaryVariant)) {
return {
variant: this.convertVariant(secondaryVariant),
source: VariantSource.SecondaryInitialVariants,
};
}
return {
variant: this.convertVariant(this.config.fallbackVariant),
source: VariantSource.FallbackConfig,
};
}
const variant =
sourceVariant ??
fallback ??
this.secondaryVariants()[key] ??
this.config.fallbackVariant;
const converted = this.convertVariant(variant);
this.debug(`[Experiment] variant for ${key} is ${converted.value}`);
return converted;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @module experiment-js-client
*/

export { ExperimentConfig, Source } from './config';
export { ExperimentConfig } from './config';
export {
AmplitudeUserProvider,
AmplitudeAnalyticsProvider,
Expand All @@ -18,5 +18,6 @@ export {
ExperimentUserProvider,
ExperimentAnalyticsProvider,
} from './types/provider';
export { Source } from './types/source';
export { ExperimentUser } from './types/user';
export { Variant, Variants } from './types/variant';
29 changes: 26 additions & 3 deletions packages/browser/src/integration/amplitude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ import {
ExperimentAnalyticsProvider,
} from '../types/provider';
import { ExperimentUser } from '../types/user';
import { safeGlobal } from '../util/global';

declare global {
// eslint-disable-next-line no-var, @typescript-eslint/no-explicit-any
var amplitude: any;
}

type AmplitudeIdentify = {
set(property: string, value: unknown): void;
unset(property: string): void;
};

type AmplitudeInstance = {
options?: AmplitudeOptions;
_ua?: AmplitudeUAParser;
logEvent(eventName: string, properties: Record<string, string>): void;
setUserProperties(userProperties: Record<string, unknown>): void;
identify(identify: AmplitudeIdentify): void;
};

type AmplitudeOptions = {
Expand Down Expand Up @@ -78,9 +90,20 @@ export class AmplitudeAnalyticsProvider implements ExperimentAnalyticsProvider {
}

track(event: ExperimentAnalyticsEvent): void {
if (event.userProperties) {
this.amplitudeInstance.setUserProperties(event.userProperties);
}
this.amplitudeInstance.logEvent(event.name, event.properties);
}

setUserProperty(event: ExperimentAnalyticsEvent): void {
// if the variant has a value, set the user property and log an event
this.amplitudeInstance.setUserProperties({
[event.userProperty]: event.variant?.value,
});
}

unsetUserProperty(event: ExperimentAnalyticsEvent): void {
// if the variant does not have a value, unset the user property
this.amplitudeInstance.identify(
new safeGlobal.amplitude.Identify().unset(event.userProperty),
);
}
}
75 changes: 51 additions & 24 deletions packages/browser/src/types/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { VariantSource } from './source';
import { ExperimentUser } from './user';
import { Variant } from './variant';

Expand All @@ -19,48 +20,74 @@ export interface ExperimentAnalyticsEvent {
* Event properties for the analytics event. Should be passed as the event
* properties to the analytics implementation provided by the
* {@link ExperimentAnalyticsProvider}.
* This is equivalent to
* ```
* {
* "key": key,
* "variant": variant,
* }
* ```
*/
properties: Record<string, string>;

/**
* User properties to identify with the user prior to sending the event.
* This is equivalent to
* ```
* {
* [userProperty]: variant
* }
* ```
*/
userProperties?: Record<string, unknown>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you added userProperty you dont need this anymore?

I am confused that we need both userProperty and userProperties.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this isn't necessary anymore but keeping it for backwards compatibility

}

/**
* Event for tracking a user's exposure to a variant. This event will not count
* towards your analytics event volume.
*/
export class ExposureEvent implements ExperimentAnalyticsEvent {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this pattern not common in js-land? In other words is it more "native" to use a function to return an interface implementation?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for simple data objections, factory functions are preferable to classes

name = '[Experiment] Exposure';
properties: Record<string, string>;
userProperties?: Record<string, unknown>;

/**
* The user exposed to the flag/experiment variant.
*/
public user: ExperimentUser;
user: ExperimentUser;

/**
* The key of the flag/experiment that the user has been exposed to.
*/
public key: string;
key: string;

/**
* The variant of the flag/experiment that the user has been exposed to.
*/
public variant: Variant;
variant: Variant;

public constructor(user: ExperimentUser, key: string, variant: Variant) {
this.key = key;
this.variant = variant;
this.properties = {
key: key,
variant: variant.value,
};
this.userProperties = {
[`[Experiment] ${key}`]: variant.value,
};
}
/**
* The user property for the flag/experiment (auto-generated from the key)
*/
userProperty: string;
}

/**
* Event for tracking a user's exposure to a variant. This event will not count
* towards your analytics event volume.
*/
export const exposureEvent = (
user: ExperimentUser,
key: string,
variant: Variant,
source: VariantSource,
): ExperimentAnalyticsEvent => {
const name = '[Experiment] Exposure';
const value = variant?.value;
const userProperty = `[Experiment] ${key}`;
return {
name,
user,
key,
variant,
userProperty,
properties: {
key,
variant: value,
source,
},
userProperties: {
[userProperty]: value,
},
};
};
22 changes: 22 additions & 0 deletions packages/browser/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,27 @@ export interface ExperimentUserProvider {
* @category Provider
*/
export interface ExperimentAnalyticsProvider {
/**
* Wraps an analytics event track call. This is typically called by the
* experiment client after setting user properties to track an
* "[Experiment] Exposure" event
* @param event see {@link ExperimentAnalyticsEvent}
*/
track(event: ExperimentAnalyticsEvent): void;

/**
* Wraps an analytics identify or set user property call. This is typically
* called by the experiment client before sending an
* "[Experiment] Exposure" event.
* @param event see {@link ExperimentAnalyticsEvent}
*/
setUserProperty?(event: ExperimentAnalyticsEvent): void;

/**
* Wraps an analytics unset user property call. This is typically
* called by the experiment client when a user has been evaluated to use
* a fallback variant.
* @param event see {@link ExperimentAnalyticsEvent}
*/
unsetUserProperty?(event: ExperimentAnalyticsEvent): void;
}
Loading