Skip to content

Commit

Permalink
Persistent assignment (#485)
Browse files Browse the repository at this point in the history
  • Loading branch information
kenny-statsig authored Aug 16, 2024
1 parent 395a9d4 commit cc847c0
Show file tree
Hide file tree
Showing 14 changed files with 2,451 additions and 16 deletions.
42 changes: 41 additions & 1 deletion src/ConfigEvaluation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EvaluationDetails } from './EvaluationDetails';
import type { StickyValues } from './interfaces/IUserPersistentStorage';
import { SecondaryExposure } from './LogEvent';

export default class ConfigEvaluation {
Expand All @@ -9,7 +10,7 @@ export default class ConfigEvaluation {
public explicit_parameters: string[] | null;
public config_delegate: string | null;
public unsupported: boolean;
public undelegated_secondary_exposures: SecondaryExposure[] | undefined;
public undelegated_secondary_exposures: SecondaryExposure[];
public is_experiment_group: boolean;
public group_name: string | null;
public evaluation_details: EvaluationDetails | undefined;
Expand Down Expand Up @@ -70,4 +71,43 @@ export default class ConfigEvaluation {
EvaluationDetails.unsupported(configSyncTime, initialUpdateTime),
);
}

public toStickyValues(): StickyValues {
return {
value: this.value,
json_value: this.json_value,
rule_id: this.rule_id,
group_name: this.group_name,
secondary_exposures: this.secondary_exposures,
undelegated_secondary_exposures: this.undelegated_secondary_exposures,
config_delegate: this.config_delegate,
explicit_parameters: this.explicit_parameters,
time: this.evaluation_details?.configSyncTime ?? Date.now(),
};
}

public static fromStickyValues(
stickyValues: StickyValues,
initialUpdateTime: number,
): ConfigEvaluation {
const evaluation = new ConfigEvaluation(
stickyValues.value,
stickyValues.rule_id,
stickyValues.group_name,
null,
stickyValues.secondary_exposures,
stickyValues.json_value,
stickyValues.explicit_parameters,
stickyValues.config_delegate,
);
evaluation.evaluation_details = EvaluationDetails.persisted(
stickyValues.time,
initialUpdateTime,
);
evaluation.undelegated_secondary_exposures =
stickyValues.undelegated_secondary_exposures;
evaluation.is_experiment_group = true;

return evaluation;
}
}
8 changes: 8 additions & 0 deletions src/EvaluationDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export class EvaluationDetails {
);
}

static persisted(configSyncTime: number, initialUpdateTime: number) {
return new EvaluationDetails(
configSyncTime,
initialUpdateTime,
'Persisted',
);
}

static make(
configSyncTime: number,
initialUpdateTime: number,
Expand Down
3 changes: 2 additions & 1 deletion src/EvaluationReason.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export type EvaluationReason =
| 'Uninitialized'
| 'Bootstrap'
| 'DataAdapter'
| 'Unsupported';
| 'Unsupported'
| 'Persisted';
94 changes: 89 additions & 5 deletions src/Evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import ip3country from 'ip3country';
import ConfigEvaluation from './ConfigEvaluation';
import { ConfigCondition, ConfigRule, ConfigSpec } from './ConfigSpec';
import { EvaluationDetails } from './EvaluationDetails';
import { UserPersistedValues } from './interfaces/IUserPersistentStorage';
import { SecondaryExposure } from './LogEvent';
import OutputLogger from './OutputLogger';
import SpecStore, { APIEntityNames } from './SpecStore';
import { ExplicitStatsigOptions, InitStrategy } from './StatsigOptions';
import { ClientInitializeResponseOptions } from './StatsigServer';
import { StatsigUser } from './StatsigUser';
import UserPersistentStorageHandler from './UserPersistentStorageHandler';
import { cloneEnforce, getSDKType, getSDKVersion } from './utils/core';
import {
arrayAny,
Expand Down Expand Up @@ -74,9 +76,8 @@ export default class Evaluator {
Record<string, Record<string, unknown>>
>;
private initialized = false;

private store: SpecStore;

private persistentStore: UserPersistentStorageHandler;
private initStrategyForIP3Country: InitStrategy;

public constructor(fetcher: StatsigFetcher, options: ExplicitStatsigOptions) {
Expand All @@ -85,6 +86,9 @@ export default class Evaluator {
this.gateOverrides = {};
this.configOverrides = {};
this.layerOverrides = {};
this.persistentStore = new UserPersistentStorageHandler(
options.userPersistentStorage,
);
}

public async init(): Promise<void> {
Expand Down Expand Up @@ -196,8 +200,11 @@ export default class Evaluator {
);
return this.getUnrecognizedEvaluation();
}
return this._evalSpec(
EvaluationContext.get(ctx.getRequestContext(), { user, spec: config }),
return this._evalConfig(
EvaluationContext.get(ctx.getRequestContext(), {
user,
spec: config,
}),
);
}

Expand Down Expand Up @@ -230,11 +237,18 @@ export default class Evaluator {
);
return this.getUnrecognizedEvaluation();
}
return this._evalSpec(
return this._evalLayer(
EvaluationContext.get(ctx.getRequestContext(), { user, spec: layer }),
);
}

public getUserPersistedValues(
user: StatsigUser,
idType: string,
): UserPersistedValues {
return this.persistentStore.load(user, idType) ?? {};
}

public getClientInitializeResponse(
inputUser: StatsigUser,
ctx: StatsigContext,
Expand Down Expand Up @@ -631,6 +645,76 @@ export default class Evaluator {
);
}

_evalConfig(ctx: EvaluationContext): ConfigEvaluation {
const { user, spec, userPersistedValues } = ctx;
if (userPersistedValues == null || !spec.isActive) {
this.persistentStore.delete(user, spec.idType, spec.name);
return this._evalSpec(ctx);
}

const stickyConfig = userPersistedValues[spec.name];
const stickyEvaluation = stickyConfig
? ConfigEvaluation.fromStickyValues(
stickyConfig,
this.store.getInitialUpdateTime(),
)
: null;

if (stickyEvaluation) {
return stickyEvaluation;
}

const evaluation = this._evalSpec(ctx);

if (evaluation.is_experiment_group) {
this.persistentStore.save(user, spec.idType, spec.name, evaluation);
}

return evaluation;
}

_evalLayer(ctx: EvaluationContext): ConfigEvaluation {
const { user, spec, userPersistedValues } = ctx;
if (!userPersistedValues) {
this.persistentStore.delete(user, spec.idType, spec.name);
return this._evalSpec(ctx);
}

const stickyConfig = userPersistedValues[spec.name];
const stickyEvaluation = stickyConfig
? ConfigEvaluation.fromStickyValues(
stickyConfig,
this.store.getInitialUpdateTime(),
)
: null;

const isAllocatedExperimentActive = (evaluation: ConfigEvaluation) =>
this._isExperimentActive(
evaluation.config_delegate
? this.store.getConfig(evaluation.config_delegate)
: null,
);

if (stickyEvaluation) {
if (isAllocatedExperimentActive(stickyEvaluation)) {
return stickyEvaluation;
} else {
this.persistentStore.delete(user, spec.idType, spec.name);
return this._evalSpec(ctx);
}
} else {
const evaluation = this._evalSpec(ctx);
if (isAllocatedExperimentActive(evaluation)) {
if (evaluation.is_experiment_group) {
this.persistentStore.save(user, spec.idType, spec.name, evaluation);
}
} else {
this.persistentStore.delete(user, spec.idType, spec.name);
}
return evaluation;
}
}

_evalSpec(ctx: EvaluationContext): ConfigEvaluation {
const evaulation = this._eval(ctx);
if (evaulation.evaluation_details) {
Expand Down
14 changes: 14 additions & 0 deletions src/StatsigOptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { IDataAdapter } from './interfaces/IDataAdapter';
import {
IUserPersistentStorage,
UserPersistedValues,
} from './interfaces/IUserPersistentStorage';
import { STATSIG_API, STATSIG_CDN } from './utils/StatsigFetcher';

const DEFAULT_RULESETS_SYNC_INTERVAL = 10 * 1000;
Expand Down Expand Up @@ -53,6 +57,7 @@ export type ExplicitStatsigOptions = {
disableRulesetsSync: boolean;
disableIdListsSync: boolean;
disableAllLogging: boolean;
userPersistentStorage: IUserPersistentStorage | null;
};

/**
Expand Down Expand Up @@ -130,6 +135,7 @@ export function OptionsWithDefaults(
disableRulesetsSync: opts.disableRulesetsSync ?? false,
disableIdListsSync: opts.disableIdListsSync ?? false,
disableAllLogging: opts.disableAllLogging ?? false,
userPersistentStorage: opts.userPersistentStorage ?? null,
};
}

Expand Down Expand Up @@ -230,3 +236,11 @@ function getNumber(
function normalizeUrl(url: string | null): string | null {
return url && url.endsWith('/') ? url.slice(0, -1) : url;
}

export type GetExperimentOptions = {
userPersistedValues?: UserPersistedValues | null;
};

export type GetLayerOptions = {
userPersistedValues?: UserPersistedValues | null;
};
32 changes: 30 additions & 2 deletions src/StatsigServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ import {
makeEmptyFeatureGate,
makeFeatureGate,
} from './FeatureGate';
import { UserPersistedValues } from './interfaces/IUserPersistentStorage';
import Layer from './Layer';
import LogEvent from './LogEvent';
import LogEventProcessor from './LogEventProcessor';
import OutputLogger from './OutputLogger';
import {
ExplicitStatsigOptions,
GetExperimentOptions,
GetLayerOptions,
OptionsLoggingCopy,
OptionsWithDefaults,
StatsigOptions,
Expand Down Expand Up @@ -298,6 +301,7 @@ export default class StatsigServer {
public getExperimentSync(
user: StatsigUser,
experimentName: string,
options?: GetExperimentOptions,
): DynamicConfig {
return this._errorBoundary.capture(
(ctx) =>
Expand All @@ -306,13 +310,15 @@ export default class StatsigServer {
StatsigContext.new({
caller: 'getExperiment',
configName: experimentName,
userPersistedValues: options?.userPersistedValues,
}),
);
}

public getExperimentWithExposureLoggingDisabledSync(
user: StatsigUser,
experimentName: string,
options?: GetExperimentOptions,
): DynamicConfig {
return this._errorBoundary.capture(
(ctx) =>
Expand All @@ -321,6 +327,7 @@ export default class StatsigServer {
StatsigContext.new({
caller: 'getExperimentWithExposureLoggingDisabled',
configName: experimentName,
userPersistedValues: options?.userPersistedValues,
}),
);
}
Expand Down Expand Up @@ -369,17 +376,26 @@ export default class StatsigServer {
* @throws Error if initialize() was not called first
* @throws Error if the layerName is not provided or not a non-empty string
*/
public getLayerSync(user: StatsigUser, layerName: string): Layer {
public getLayerSync(
user: StatsigUser,
layerName: string,
options?: GetLayerOptions,
): Layer {
return this._errorBoundary.capture(
(ctx) => this.getLayerImpl(user, layerName, ExposureLogging.Enabled, ctx),
() => new Layer(layerName),
StatsigContext.new({ caller: 'getLayer', configName: layerName }),
StatsigContext.new({
caller: 'getLayer',
configName: layerName,
userPersistedValues: options?.userPersistedValues,
}),
);
}

public getLayerWithExposureLoggingDisabledSync(
user: StatsigUser,
layerName: string,
options?: GetLayerOptions,
): Layer {
return this._errorBoundary.capture(
(ctx) =>
Expand All @@ -388,6 +404,7 @@ export default class StatsigServer {
StatsigContext.new({
caller: 'getLayerWithExposureLoggingDisabled',
configName: layerName,
userPersistedValues: options?.userPersistedValues,
}),
);
}
Expand Down Expand Up @@ -417,6 +434,17 @@ export default class StatsigServer {

//#endregion

public getUserPersistedValues(
user: StatsigUser,
idType: string,
): UserPersistedValues {
return this._errorBoundary.capture(
() => this._evaluator.getUserPersistedValues(user, idType),
() => ({}),
StatsigContext.new({ caller: 'getUserPersistedValues' }),
);
}

/**
* Log an event for data analysis and alerting or to measure the impact of an experiment
* @throws Error if initialize() was not called first
Expand Down
Loading

0 comments on commit cc847c0

Please sign in to comment.