Skip to content

Commit 92003fc

Browse files
feat(remote-config): add optional exposurePercent field to ExperimentValue (#3096)
* feat(remote-config): add optional exposurePercent field to ExperimentValue * document the expected range for the exposure * correct field ordering in ExperimentValue API report * validate experiment exposure percent range * Validate parameterGroups for exposure
1 parent 8b9b7a7 commit 92003fc

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

etc/firebase-admin.remote-config.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface ExperimentParameterValue {
5555
// @public
5656
export interface ExperimentValue {
5757
experimentId: string;
58+
exposurePercent?: number;
5859
variantValue: ExperimentVariantValue[];
5960
}
6061

src/remote-config/remote-config-api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,12 @@ export interface ExperimentValue {
451451
* served by the Experiment.
452452
*/
453453
variantValue: ExperimentVariantValue[];
454+
455+
/**
456+
* The percentage of users included in the Experiment, represented as a number
457+
* between 0 and 100.
458+
*/
459+
exposurePercent?: number;
454460
}
455461

456462
/**

src/remote-config/remote-config.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
InAppDefaultValue,
3535
ServerConfig,
3636
RemoteConfigParameterValue,
37+
ExperimentParameterValue,
3738
EvaluationContext,
3839
ServerTemplateData,
3940
NamedCondition,
@@ -243,6 +244,8 @@ class RemoteConfigTemplateImpl implements RemoteConfigTemplate {
243244
this.parameters = {};
244245
}
245246

247+
validateAllParameterExposures(this.parameters);
248+
246249
if (typeof config.parameterGroups !== 'undefined') {
247250
if (!validator.isNonNullObject(config.parameterGroups)) {
248251
throw new FirebaseRemoteConfigError(
@@ -254,6 +257,12 @@ class RemoteConfigTemplateImpl implements RemoteConfigTemplate {
254257
this.parameterGroups = {};
255258
}
256259

260+
for (const group of Object.values(this.parameterGroups)) {
261+
if (validator.isNonNullObject(group) && validator.isNonNullObject(group.parameters)) {
262+
validateAllParameterExposures(group.parameters);
263+
}
264+
}
265+
257266
if (typeof config.conditions !== 'undefined') {
258267
if (!validator.isArray(config.conditions)) {
259268
throw new FirebaseRemoteConfigError(
@@ -448,6 +457,52 @@ class ServerConfigImpl implements ServerConfig {
448457
}
449458
}
450459

460+
function validateAllParameterExposures(
461+
parameters: { [key: string]: RemoteConfigParameter }
462+
): void {
463+
// Walk each parameter and validate any exposurePercent present in
464+
// conditional values only. Experiment exposure is condition-scoped.
465+
for (const [parameterName, parameter] of Object.entries(parameters)) {
466+
if (!validator.isNonNullObject(parameter)) {
467+
continue;
468+
}
469+
470+
if (!validator.isNonNullObject(parameter.conditionalValues)) {
471+
continue;
472+
}
473+
474+
for (const conditionalValue of Object.values(parameter.conditionalValues)) {
475+
validateExperimentExposurePercent(conditionalValue, parameterName);
476+
}
477+
}
478+
}
479+
480+
function validateExperimentExposurePercent(
481+
parameterValue: RemoteConfigParameterValue | undefined,
482+
parameterName: string,
483+
): void {
484+
// Only experiment-backed values can carry `exposurePercent`.
485+
// For other parameter value types, this validator is a no-op.
486+
if (!validator.isNonNullObject(parameterValue) ||
487+
!validator.isNonNullObject((parameterValue as ExperimentParameterValue).experimentValue)) {
488+
return;
489+
}
490+
491+
const exposurePercent = (parameterValue as ExperimentParameterValue).experimentValue.exposurePercent;
492+
// `exposurePercent` is optional. If absent, leave behavior unchanged.
493+
if (typeof exposurePercent === 'undefined') {
494+
return;
495+
}
496+
497+
// Enforce public contract: numeric and within [0, 100].
498+
if (!validator.isNumber(exposurePercent) || !Number.isFinite(exposurePercent) ||
499+
exposurePercent < 0 || exposurePercent > 100) {
500+
throw new FirebaseRemoteConfigError(
501+
'invalid-argument',
502+
`Experiment exposure percent must be between 0 and 100 (${parameterName})`);
503+
}
504+
}
505+
451506
/**
452507
* Remote Config dataplane template data implementation.
453508
*/

test/unit/remote-config/remote-config.spec.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ describe('RemoteConfig', () => {
134134
variantValue: [
135135
{ variantId: 'variant_A', value: 'true' },
136136
{ variantId: 'variant_B', noChange: true }
137-
]
137+
],
138+
exposurePercent: 25,
138139
}
139140
}
140141
},
@@ -233,7 +234,8 @@ describe('RemoteConfig', () => {
233234
variantValue: [
234235
{ variantId: 'variant_A', value: 'true' },
235236
{ variantId: 'variant_B', noChange: true }
236-
]
237+
],
238+
exposurePercent: 25,
237239
}
238240
}
239241
},
@@ -607,6 +609,48 @@ describe('RemoteConfig', () => {
607609
});
608610
});
609611

612+
it('should throw if experiment exposure percent is out of range', () => {
613+
sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE);
614+
(sourceTemplate.parameters as any).experiment_enabled
615+
.conditionalValues.ios.experimentValue.exposurePercent = 101;
616+
const jsonString = JSON.stringify(sourceTemplate);
617+
expect(() => remoteConfig.createTemplateFromJSON(jsonString))
618+
.to.throw('Experiment exposure percent must be between 0 and 100 (experiment_enabled)');
619+
});
620+
621+
it('should accept experiment exposure percent for boundary and middle values', () => {
622+
[0, 52, 100].forEach((validExposurePercent) => {
623+
sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE);
624+
(sourceTemplate.parameters as any).experiment_enabled
625+
.conditionalValues.ios.experimentValue.exposurePercent = validExposurePercent;
626+
const jsonString = JSON.stringify(sourceTemplate);
627+
expect(() => remoteConfig.createTemplateFromJSON(jsonString)).to.not.throw();
628+
});
629+
});
630+
631+
it('should throw if experiment exposure percent in a parameter group is out of range', () => {
632+
sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE);
633+
(sourceTemplate.parameterGroups as any).new_menu.parameters.pumpkin_spice_season
634+
.conditionalValues.android_en = {
635+
experimentValue: { experimentId: 'exp_1', exposurePercent: 101, variantValue: [] },
636+
};
637+
const jsonString = JSON.stringify(sourceTemplate);
638+
expect(() => remoteConfig.createTemplateFromJSON(jsonString))
639+
.to.throw('Experiment exposure percent must be between 0 and 100 (pumpkin_spice_season)');
640+
});
641+
642+
it('should accept valid experiment exposure percent in a parameter group', () => {
643+
[0, 50, 100].forEach((validExposurePercent) => {
644+
sourceTemplate = deepCopy(REMOTE_CONFIG_RESPONSE);
645+
(sourceTemplate.parameterGroups as any).new_menu.parameters.pumpkin_spice_season
646+
.conditionalValues.android_en = {
647+
experimentValue: { experimentId: 'exp_1', exposurePercent: validExposurePercent, variantValue: [] },
648+
};
649+
const jsonString = JSON.stringify(sourceTemplate);
650+
expect(() => remoteConfig.createTemplateFromJSON(jsonString)).to.not.throw();
651+
});
652+
});
653+
610654
it('should succeed when a valid json string is provided', () => {
611655
const jsonString = JSON.stringify(REMOTE_CONFIG_RESPONSE);
612656
const newTemplate = remoteConfig.createTemplateFromJSON(jsonString);
@@ -652,6 +696,7 @@ describe('RemoteConfig', () => {
652696
expect(p4.conditionalValues).to.not.be.undefined;
653697
const experimentParam = p4.conditionalValues!['ios'] as ExperimentParameterValue;
654698
expect(experimentParam.experimentValue.experimentId).to.equal('experiment_1');
699+
expect(experimentParam.experimentValue.exposurePercent).to.equal(25);
655700
expect(experimentParam.experimentValue.variantValue.length).to.equal(2);
656701
expect(experimentParam.experimentValue.variantValue[0]).to.deep.equal({ variantId: 'variant_A', value: 'true' });
657702
expect(experimentParam.experimentValue.variantValue[1]).to.deep.equal({ variantId: 'variant_B', noChange: true });
@@ -1668,6 +1713,19 @@ describe('RemoteConfig', () => {
16681713
.should.eventually.be.rejected.and.have.property(
16691714
'message', 'Remote Config conditions must be an array');
16701715
});
1716+
1717+
it('should reject when API response contains invalid experiment exposure percent', () => {
1718+
const response = deepCopy(REMOTE_CONFIG_RESPONSE);
1719+
(response.parameters as any).experiment_enabled
1720+
.conditionalValues.ios.experimentValue.exposurePercent = 101;
1721+
const stub = sinon
1722+
.stub(RemoteConfigApiClient.prototype, operationName)
1723+
.resolves(response);
1724+
stubs.push(stub);
1725+
return rcOperation()
1726+
.should.eventually.be.rejected.and.have.property(
1727+
'message', 'Experiment exposure percent must be between 0 and 100 (experiment_enabled)');
1728+
});
16711729
}
16721730

16731731
function runValidResponseTests(rcOperation: () => Promise<RemoteConfigTemplate>,

0 commit comments

Comments
 (0)