Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions etc/firebase-admin.remote-config.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface ExperimentParameterValue {
// @public
export interface ExperimentValue {
experimentId: string;
exposurePercent?: number;
variantValue: ExperimentVariantValue[];
}

Expand Down
6 changes: 6 additions & 0 deletions src/remote-config/remote-config-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,12 @@ export interface ExperimentValue {
* served by the Experiment.
*/
variantValue: ExperimentVariantValue[];

/**
* The percentage of users included in the Experiment, represented as a number
* between 0 and 100.
*/
exposurePercent?: number;
Comment thread
tusharkhandelwal8 marked this conversation as resolved.
Comment thread
tusharkhandelwal8 marked this conversation as resolved.
}

/**
Expand Down
49 changes: 49 additions & 0 deletions src/remote-config/remote-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
InAppDefaultValue,
ServerConfig,
RemoteConfigParameterValue,
ExperimentParameterValue,
EvaluationContext,
ServerTemplateData,
NamedCondition,
Expand Down Expand Up @@ -243,6 +244,8 @@ class RemoteConfigTemplateImpl implements RemoteConfigTemplate {
this.parameters = {};
}

validateExperimentExposurePercents(this.parameters);
Comment thread
tusharkhandelwal8 marked this conversation as resolved.
Outdated

if (typeof config.parameterGroups !== 'undefined') {
if (!validator.isNonNullObject(config.parameterGroups)) {
throw new FirebaseRemoteConfigError(
Expand Down Expand Up @@ -448,6 +451,52 @@ class ServerConfigImpl implements ServerConfig {
}
}

function validateExperimentExposurePercents(
parameters: { [key: string]: RemoteConfigParameter }
): void {
// Walk each parameter and validate any exposurePercent present in
// conditional values only. Experiment exposure is condition-scoped.
for (const [parameterName, parameter] of Object.entries(parameters)) {
if (!validator.isNonNullObject(parameter)) {
continue;
}

if (!validator.isNonNullObject(parameter.conditionalValues)) {
continue;
}

for (const conditionalValue of Object.values(parameter.conditionalValues)) {
validateExperimentExposurePercent(conditionalValue, parameterName);
}
}
}

function validateExperimentExposurePercent(
Comment thread
tusharkhandelwal8 marked this conversation as resolved.
parameterValue: RemoteConfigParameterValue | undefined,
parameterName: string,
): void {
// Only experiment-backed values can carry `exposurePercent`.
// For other parameter value types, this validator is a no-op.
if (!validator.isNonNullObject(parameterValue) ||
!validator.isNonNullObject((parameterValue as ExperimentParameterValue).experimentValue)) {
return;
}

const exposurePercent = (parameterValue as ExperimentParameterValue).experimentValue.exposurePercent;
// `exposurePercent` is optional. If absent, leave behavior unchanged.
if (typeof exposurePercent === 'undefined') {
return;
}

// Enforce public contract: numeric and within [0, 100].
if (!validator.isNumber(exposurePercent) || !Number.isFinite(exposurePercent) ||
exposurePercent < 0 || exposurePercent > 100) {
throw new FirebaseRemoteConfigError(
'invalid-argument',
`Experiment exposure percent must be between 0 and 100 (${parameterName})`);
}
}

/**
* Remote Config dataplane template data implementation.
*/
Expand Down
39 changes: 37 additions & 2 deletions test/unit/remote-config/remote-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ describe('RemoteConfig', () => {
variantValue: [
{ variantId: 'variant_A', value: 'true' },
{ variantId: 'variant_B', noChange: true }
]
],
exposurePercent: 25,
}
}
},
Expand Down Expand Up @@ -233,7 +234,8 @@ describe('RemoteConfig', () => {
variantValue: [
{ variantId: 'variant_A', value: 'true' },
{ variantId: 'variant_B', noChange: true }
]
],
exposurePercent: 25,
}
}
},
Expand Down Expand Up @@ -607,6 +609,25 @@ describe('RemoteConfig', () => {
});
});

it('should throw if experiment exposure percent is out of range', () => {
sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE);
(sourceTemplate.parameters as any).experiment_enabled
.conditionalValues.ios.experimentValue.exposurePercent = 101;
const jsonString = JSON.stringify(sourceTemplate);
expect(() => remoteConfig.createTemplateFromJSON(jsonString))
.to.throw('Experiment exposure percent must be between 0 and 100 (experiment_enabled)');
});

it('should accept experiment exposure percent for boundary and middle values', () => {
[0, 52, 100].forEach((validExposurePercent) => {
sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE);
(sourceTemplate.parameters as any).experiment_enabled
.conditionalValues.ios.experimentValue.exposurePercent = validExposurePercent;
const jsonString = JSON.stringify(sourceTemplate);
expect(() => remoteConfig.createTemplateFromJSON(jsonString)).to.not.throw();
});
});

it('should succeed when a valid json string is provided', () => {
const jsonString = JSON.stringify(REMOTE_CONFIG_RESPONSE);
const newTemplate = remoteConfig.createTemplateFromJSON(jsonString);
Expand Down Expand Up @@ -652,6 +673,7 @@ describe('RemoteConfig', () => {
expect(p4.conditionalValues).to.not.be.undefined;
const experimentParam = p4.conditionalValues!['ios'] as ExperimentParameterValue;
expect(experimentParam.experimentValue.experimentId).to.equal('experiment_1');
expect(experimentParam.experimentValue.exposurePercent).to.equal(25);
expect(experimentParam.experimentValue.variantValue.length).to.equal(2);
expect(experimentParam.experimentValue.variantValue[0]).to.deep.equal({ variantId: 'variant_A', value: 'true' });
expect(experimentParam.experimentValue.variantValue[1]).to.deep.equal({ variantId: 'variant_B', noChange: true });
Expand Down Expand Up @@ -1668,6 +1690,19 @@ describe('RemoteConfig', () => {
.should.eventually.be.rejected.and.have.property(
'message', 'Remote Config conditions must be an array');
});

it('should reject when API response contains invalid experiment exposure percent', () => {
const response = deepCopy(REMOTE_CONFIG_RESPONSE);
(response.parameters as any).experiment_enabled
.conditionalValues.ios.experimentValue.exposurePercent = 101;
const stub = sinon
.stub(RemoteConfigApiClient.prototype, operationName)
.resolves(response);
stubs.push(stub);
return rcOperation()
.should.eventually.be.rejected.and.have.property(
'message', 'Experiment exposure percent must be between 0 and 100 (experiment_enabled)');
});
}

function runValidResponseTests(rcOperation: () => Promise<RemoteConfigTemplate>,
Expand Down
Loading