Skip to content

Commit

Permalink
Merge pull request #369 from statsig-io/config-override-interface
Browse files Browse the repository at this point in the history
Sticky bucketing storage adapter
  • Loading branch information
kenny-statsig authored Aug 3, 2023
2 parents 8d4ad0e + b59c674 commit 291b92a
Show file tree
Hide file tree
Showing 6 changed files with 279 additions and 23 deletions.
7 changes: 7 additions & 0 deletions src/DynamicConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class DynamicConfig {
private allocatedExperimentName: string;
private evaluationDetails: EvaluationDetails;
private onDefaultValueFallback: OnDefaultValueFallback | null = null;
private isExperimentActive: boolean | null;

public constructor(
configName: string,
Expand All @@ -29,6 +30,7 @@ export default class DynamicConfig {
onDefaultValueFallback: OnDefaultValueFallback | null = null,
groupName: string | null = null,
idType: string | null = null,
isExperimentActive: boolean | null = null,
) {
this.name = configName;
this.value = JSON.parse(JSON.stringify(configValue ?? {}));
Expand All @@ -39,6 +41,7 @@ export default class DynamicConfig {
this.onDefaultValueFallback = onDefaultValueFallback;
this.groupName = groupName;
this.idType = idType;
this.isExperimentActive = isExperimentActive;
}

public get<T>(
Expand Down Expand Up @@ -114,4 +117,8 @@ export default class DynamicConfig {
public _getAllocatedExperimentName(): string {
return this.allocatedExperimentName;
}

public getIsExperimentActive(): boolean | null {
return this.isExperimentActive;
}
}
30 changes: 29 additions & 1 deletion src/StatsigClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export interface IHasStatsigInternal {
getOptions(): StatsigSDKOptions;
getCurrentUser(): StatsigUser | null;
getCurrentUserCacheKey(): UserCacheKey;
getCurrentUserUnitID(idType: string): string | null;
getCurrentUserID(): string | null;
getSDKKey(): string;
getStatsigMetadata(): Record<string, string | number>;
getErrorBoundary(): ErrorBoundary;
Expand Down Expand Up @@ -178,6 +180,32 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
() => ({ v1: '', v2: '' }),
);
}
public getCurrentUserUnitID(idType: string): string | null {
return this.errorBoundary.capture(
'getCurrentUserUnitID',
() => this.getUnitID(this.getCurrentUser(), idType),
() => '',
);
}
public getCurrentUserID(): string | null {
return this.errorBoundary.capture(
'getCurrentUserID',
() => this.getUnitID(this.getCurrentUser(), 'userid'),
() => '',
);
}
private getUnitID(user: StatsigUser | null, idType: string): string | null {
if (!user) {
return null;
}
if (idType.toLowerCase() === 'userid') {
return user.userID?.toString() ?? null;
}
if (user.customIDs) {
user.customIDs[idType] ?? user.customIDs[idType.toLowerCase()]
}
return null;
}

public getStatsigMetadata(): Record<string, string | number> {
return this.errorBoundary.capture(
Expand Down Expand Up @@ -332,7 +360,7 @@ export default class StatsigClient implements IHasStatsigInternal, IStatsig {
this.initCalled = true;
if (StatsigAsyncStorage.asyncStorage) {
await this.identity.initAsync();
await this.store.loadFromAsyncStorage();
await this.store.loadAsync();
}

this.onCacheLoadedForReact?.();
Expand Down
12 changes: 12 additions & 0 deletions src/StatsigSDKOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export type GateEvaluationCallback = (
}
) => void;

export interface UserPersistentStorageInterface {
load(userID: string): string
save(userID: string, data: string): void
}

export type StatsigOptions = {
api?: string;
disableCurrentPageLogging?: boolean;
Expand All @@ -54,6 +59,7 @@ export type StatsigOptions = {
fetchMode?: FetchMode;
disableLocalOverrides?: boolean;
gateEvaluationCallback?: GateEvaluationCallback;
userPersistentStorage?: UserPersistentStorageInterface;
};

export enum LogLevel {
Expand Down Expand Up @@ -94,6 +100,7 @@ export default class StatsigSDKOptions {
private fetchMode: FetchMode;
private disableLocalOverrides: boolean;
private gateEvaluationCallback: GateEvaluationCallback | null;
private userPersistentStorage: UserPersistentStorageInterface | null;

constructor(options?: StatsigOptions | null) {
if (options == null) {
Expand Down Expand Up @@ -146,6 +153,7 @@ export default class StatsigSDKOptions {
this.fetchMode = options.fetchMode ?? 'network-only';
this.disableLocalOverrides = options?.disableLocalOverrides ?? false;
this.gateEvaluationCallback = options?.gateEvaluationCallback ?? null;
this.userPersistentStorage = options?.userPersistentStorage ?? null;
}

getApi(): string {
Expand Down Expand Up @@ -240,6 +248,10 @@ export default class StatsigSDKOptions {
return this.gateEvaluationCallback;
}

getUserPersistentStorage(): UserPersistentStorageInterface | null {
return this.userPersistentStorage;
}

private normalizeNumberInput(
input: number | undefined,
bounds: BoundedNumberInput,
Expand Down
107 changes: 85 additions & 22 deletions src/StatsigStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from './utils/Hashing';
import StatsigAsyncStorage from './utils/StatsigAsyncStorage';
import StatsigLocalStorage from './utils/StatsigLocalStorage';
import { UserPersistentStorageInterface } from './StatsigSDKOptions';

export enum EvaluationReason {
Network = 'Network',
Expand Down Expand Up @@ -96,6 +97,10 @@ type UserCacheValues = APIInitializeDataWithPrefetchedUsers & {
user_hash?: string;
};

type UserPersistentStorageData = {
experiments: Record<string, unknown>;
};

const MAX_USER_VALUE_CACHED = 10;

export default class StatsigStore {
Expand All @@ -113,6 +118,8 @@ export default class StatsigStore {
private stickyDeviceExperiments: Record<string, APIDynamicConfig>;
private userCacheKey: UserCacheKey;
private reason: EvaluationReason;
private userPersistentStorageAdapter: UserPersistentStorageInterface | null;
private userPersistentStorageData: UserPersistentStorageData;

public constructor(
sdkInternal: IHasStatsigInternal,
Expand All @@ -133,20 +140,36 @@ export default class StatsigStore {
this.stickyDeviceExperiments = {};
this.loaded = false;
this.reason = EvaluationReason.Uninitialized;
this.userPersistentStorageAdapter = this.sdkInternal
.getOptions()
.getUserPersistentStorage();
this.userPersistentStorageData = { experiments: {} };

if (initializeValues) {
this.bootstrap(initializeValues);
} else {
this.loadFromLocalStorage();
this.load();
}
}

public load(): void {
this.loadFromLocalStorage();
this.partialLoadFromPersistentStorageAdapter();
}

public async loadAsync(): Promise<void> {
await this.loadFromAsyncStorage();
this.partialLoadFromPersistentStorageAdapter();
}

public updateUser(isUserPrefetched: boolean): number | null {
this.userCacheKey = this.sdkInternal.getCurrentUserCacheKey();
return this.setUserValueFromCache(isUserPrefetched);
const evaluationTime = this.setUserValueFromCache(isUserPrefetched);
this.partialLoadFromPersistentStorageAdapter();
return evaluationTime;
}

public async loadFromAsyncStorage(): Promise<void> {
private async loadFromAsyncStorage(): Promise<void> {
this.parseCachedValues(
await StatsigAsyncStorage.getItemAsync(INTERNAL_STORE_KEY),
await StatsigAsyncStorage.getItemAsync(STICKY_DEVICE_EXPERIMENTS_KEY),
Expand Down Expand Up @@ -198,6 +221,44 @@ export default class StatsigStore {
this.loaded = true;
}

// Currently only loads experiments, cannot rely on storage adapter for all user values.
private partialLoadFromPersistentStorageAdapter(): void {
if (this.userPersistentStorageAdapter) {
const userID = this.sdkInternal.getCurrentUserID();
if (userID) {
try {
this.userPersistentStorageData = JSON.parse(
this.userPersistentStorageAdapter.load(userID),
) as UserPersistentStorageData;
} catch (e) {
console.warn('Failed to load from user persistent storage.', e);
}
this.userValues.sticky_experiments = this.userPersistentStorageData
.experiments as Record<string, APIDynamicConfig>;
}
}
}

private saveStickyExperimentsToPersistentStorageAdapter(): void {
if (this.userPersistentStorageAdapter) {
const userID = this.sdkInternal.getCurrentUserID();
if (userID) {
const data: UserPersistentStorageData = {
...this.userPersistentStorageData,
experiments: this.userValues.sticky_experiments,
};
try {
this.userPersistentStorageAdapter.save(userID, JSON.stringify(data));
} catch (e) {
console.warn(
'Failed to save user experiment values to persistent storage.',
e,
);
}
}
}
}

public isLoaded(): boolean {
return this.loaded;
}
Expand Down Expand Up @@ -737,16 +798,18 @@ export default class StatsigStore {
isLayer: boolean,
details: EvaluationDetails,
): APIDynamicConfig | undefined {
const key = this.getHashedSpecName(name);

// We don't want sticky behavior. Clear any sticky values and return latest.
if (!keepDeviceValue) {
this.removeStickyValue(name);
this.removeStickyValue(key);
return latestValue;
}

// If there is no sticky value, save latest as sticky and return latest.
const stickyValue = this.getStickyValue(name);
const stickyValue = this.getStickyValue(key);
if (!stickyValue) {
this.attemptToSaveStickyValue(name, latestValue);
this.attemptToSaveStickyValue(key, latestValue);
return latestValue;
}

Expand All @@ -767,9 +830,9 @@ export default class StatsigStore {
}

if (latestValue?.is_experiment_active == true) {
this.attemptToSaveStickyValue(name, latestValue);
this.attemptToSaveStickyValue(key, latestValue);
} else {
this.removeStickyValue(name);
this.removeStickyValue(key);
}

return latestValue;
Expand All @@ -790,19 +853,18 @@ export default class StatsigStore {
this.makeOnConfigDefaultValueFallback(this.sdkInternal.getCurrentUser()),
apiConfig?.group_name,
apiConfig?.id_type,
apiConfig?.is_experiment_active,
);
}

private getStickyValue(name: string) {
const key = this.getHashedSpecName(name);

private getStickyValue(key: string): APIDynamicConfig | null {
return (
this.userValues?.sticky_experiments[key] ??
this.stickyDeviceExperiments[key]
);
}

private attemptToSaveStickyValue(name: string, config?: APIDynamicConfig) {
private attemptToSaveStickyValue(key: string, config?: APIDynamicConfig) {
if (
!config ||
!config.is_user_in_experiment ||
Expand All @@ -811,7 +873,6 @@ export default class StatsigStore {
return;
}

const key = this.getHashedSpecName(name);
if (config.is_device_based === true) {
// save sticky values in memory
this.stickyDeviceExperiments[key] = config;
Expand All @@ -822,28 +883,30 @@ export default class StatsigStore {
this.saveStickyValuesToStorage();
}

private removeStickyValue(name: string) {
private removeStickyValue(key: string) {
if (
Object.keys(this.userValues?.sticky_experiments ?? {}).length === 0 &&
Object.keys(this.stickyDeviceExperiments ?? {}).length === 0
) {
return;
}

const key = this.getHashedSpecName(name);

delete this.userValues?.sticky_experiments[key];
delete this.stickyDeviceExperiments[key];
this.saveStickyValuesToStorage();
}

private saveStickyValuesToStorage() {
this.values[this.userCacheKey.v2] = this.userValues;
this.setItemToStorage(INTERNAL_STORE_KEY, JSON.stringify(this.values));
this.setItemToStorage(
STICKY_DEVICE_EXPERIMENTS_KEY,
JSON.stringify(this.stickyDeviceExperiments),
);
if (this.userPersistentStorageAdapter) {
this.saveStickyExperimentsToPersistentStorageAdapter();
} else {
this.values[this.userCacheKey.v2] = this.userValues;
this.setItemToStorage(INTERNAL_STORE_KEY, JSON.stringify(this.values));
this.setItemToStorage(
STICKY_DEVICE_EXPERIMENTS_KEY,
JSON.stringify(this.stickyDeviceExperiments),
);
}
}

public getGlobalEvaluationDetails(): EvaluationDetails {
Expand Down
Loading

0 comments on commit 291b92a

Please sign in to comment.