Skip to content

Commit fd049e2

Browse files
author
Athira M
committed
feat: Add ABT support for remote config
1 parent 06398f6 commit fd049e2

File tree

5 files changed

+185
-1
lines changed

5 files changed

+185
-1
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import { Storage } from '../storage/storage';
18+
import { FirebaseExperimentDescription } from '../public_types';
19+
20+
export class Experiment {
21+
constructor(private readonly storage: Storage) {}
22+
23+
async updateActiveExperiments(
24+
latestExperiments: FirebaseExperimentDescription[]
25+
): Promise<void> {
26+
const currentActiveExperiments = await this.storage.getActiveExperiments() || new Set<string>();
27+
const experimentInfoMap = this.createExperimentInfoMap(latestExperiments);
28+
this.addActiveExperiments(currentActiveExperiments, experimentInfoMap);
29+
this.removeInactiveExperiments(currentActiveExperiments, experimentInfoMap);
30+
return this.storage.setActiveExperiments(new Set(experimentInfoMap.keys()));
31+
}
32+
33+
private createExperimentInfoMap(
34+
latestExperiments: FirebaseExperimentDescription[]
35+
): Map<string, FirebaseExperimentDescription> {
36+
const experimentInfoMap = new Map<string, FirebaseExperimentDescription>();
37+
for (const experiment of latestExperiments) {
38+
experimentInfoMap.set(experiment.experimentId, experiment);
39+
}
40+
return experimentInfoMap;
41+
}
42+
43+
private addActiveExperiments(
44+
currentActiveExperiments: Set<string>,
45+
experimentInfoMap: Map<string, FirebaseExperimentDescription>): void {
46+
for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) {
47+
if (!currentActiveExperiments.has(experimentId)) {
48+
this.addExperimentToAnalytics(experimentId, experimentInfo.variantId);
49+
}
50+
}
51+
}
52+
53+
private removeInactiveExperiments(
54+
currentActiveExperiments: Set<string>,
55+
experimentInfoMap: Map<string, FirebaseExperimentDescription>
56+
): void {
57+
for (const experimentId of currentActiveExperiments) {
58+
if (!experimentInfoMap.has(experimentId)) {
59+
this.removeExperimentFromAnalytics(experimentId);
60+
}
61+
}
62+
}
63+
64+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
65+
private addExperimentToAnalytics(experimentId: string, variantId: string): void {
66+
// TODO
67+
}
68+
69+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
70+
private removeExperimentFromAnalytics(experimentId: string): void {
71+
// TODO
72+
}
73+
}

packages/remote-config/src/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { ERROR_FACTORY, ErrorCode, hasErrorCode } from './errors';
3636
import { RemoteConfig as RemoteConfigImpl } from './remote_config';
3737
import { Value as ValueImpl } from './value';
3838
import { LogLevel as FirebaseLogLevel } from '@firebase/logger';
39+
import { Experiment } from './abt/experiment';
3940

4041
/**
4142
*
@@ -111,12 +112,15 @@ export async function activate(remoteConfig: RemoteConfig): Promise<boolean> {
111112
// config.
112113
return false;
113114
}
115+
const experiment = new Experiment(rc._storage);
114116
await Promise.all([
115117
rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config),
116118
rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag),
117119
rc._storage.setActiveConfigTemplateVersion(
118120
lastSuccessfulFetchResponse.templateVersion
119-
)
121+
),
122+
experiment.updateActiveExperiments(lastSuccessfulFetchResponse.experiments),
123+
rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag)
120124
]);
121125
return true;
122126
}

packages/remote-config/src/storage/storage.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface RealtimeBackoffMetadata {
7171
type ProjectNamespaceKeyFieldValue =
7272
| 'active_config'
7373
| 'active_config_etag'
74+
| 'active_experiments'
7475
| 'last_fetch_status'
7576
| 'last_successful_fetch_timestamp_millis'
7677
| 'last_successful_fetch_response'
@@ -165,6 +166,14 @@ export abstract class Storage {
165166
return this.set<string>('active_config_etag', etag);
166167
}
167168

169+
getActiveExperiments(): Promise<Set<string> | undefined> {
170+
return this.get<Set<string>>('active_experiments');
171+
}
172+
173+
setActiveExperiments(experiments:Set<string>): Promise<void> {
174+
return this.set<Set<string>>('active_experiments', experiments);
175+
}
176+
168177
getThrottleMetadata(): Promise<ThrottleMetadata | undefined> {
169178
return this.get<ThrottleMetadata>('throttle_metadata');
170179
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import '../setup';
18+
import { expect } from 'chai';
19+
import * as sinon from 'sinon';
20+
import { Experiment } from '../../src/abt/experiment';
21+
import { FirebaseExperimentDescription } from '../../src/public_types';
22+
import { Storage } from '../../src/storage/storage';
23+
24+
describe('Experiment', () => {
25+
const storage = {} as Storage;
26+
const experiment = new Experiment(storage);
27+
28+
describe('updateActiveExperiments', () => {
29+
beforeEach(() => {
30+
storage.getActiveExperiments = sinon.stub();
31+
storage.setActiveExperiments = sinon.stub();
32+
});
33+
34+
it('adds mew experiments to storage', async () => {
35+
const latestExperiments: FirebaseExperimentDescription[] = [
36+
{
37+
experimentId: '_exp_3',
38+
variantId: '1',
39+
experimentStartTime: '0',
40+
triggerTimeoutMillis: '0',
41+
timeToLiveMillis: '0'
42+
},
43+
{
44+
experimentId: '_exp_1',
45+
variantId: '2',
46+
experimentStartTime: '0',
47+
triggerTimeoutMillis: '0',
48+
timeToLiveMillis: '0'
49+
},
50+
{
51+
experimentId: '_exp_2',
52+
variantId: '1',
53+
experimentStartTime: '0',
54+
triggerTimeoutMillis: '0',
55+
timeToLiveMillis: '0'
56+
},
57+
];
58+
const expectedStoredExperiments = new Set(['_exp_3', '_exp_1', '_exp_2']);
59+
storage.getActiveExperiments = sinon.stub().returns(new Set(['_exp_1', '_exp_2']));
60+
61+
62+
await experiment.updateActiveExperiments(latestExperiments);
63+
64+
expect(storage.setActiveExperiments).to.have.been.calledWith(expectedStoredExperiments);
65+
});
66+
67+
it('removes missing experiment in fetch response from storage', async () => {
68+
const latestExperiments: FirebaseExperimentDescription[] = [
69+
{
70+
experimentId: '_exp_1',
71+
variantId: '2',
72+
experimentStartTime: '0',
73+
triggerTimeoutMillis: '0',
74+
timeToLiveMillis: '0'
75+
}
76+
];
77+
const expectedStoredExperiments = new Set(['_exp_1']);
78+
storage.getActiveExperiments = sinon.stub().returns(new Set(['_exp_1', '_exp_2']));
79+
80+
81+
await experiment.updateActiveExperiments(latestExperiments);
82+
83+
expect(storage.setActiveExperiments).to.have.been.calledWith(expectedStoredExperiments);
84+
});
85+
});
86+
});

packages/remote-config/test/remote_config.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import * as api from '../src/api';
4747
import { fetchAndActivate } from '../src';
4848
import { restore } from 'sinon';
4949
import { RealtimeHandler } from '../src/client/realtime_handler';
50+
import { Experiment } from '../src/abt/experiment';
5051

5152
describe('RemoteConfig', () => {
5253
const ACTIVE_CONFIG = {
@@ -398,6 +399,8 @@ describe('RemoteConfig', () => {
398399
"timeToLiveMillis" : "15552000000"
399400
}];
400401

402+
let sandbox: sinon.SinonSandbox;
403+
let updateActiveExperimentsStub: sinon.SinonStub;
401404
let getLastSuccessfulFetchResponseStub: sinon.SinonStub;
402405
let getActiveConfigEtagStub: sinon.SinonStub;
403406
let getActiveConfigTemplateVersionStub: sinon.SinonStub;
@@ -406,6 +409,8 @@ describe('RemoteConfig', () => {
406409
let setActiveConfigTemplateVersionStub: sinon.SinonStub;
407410

408411
beforeEach(() => {
412+
sandbox = sinon.createSandbox();
413+
updateActiveExperimentsStub = sandbox.stub(Experiment.prototype, 'updateActiveExperiments');
409414
getLastSuccessfulFetchResponseStub = sinon.stub();
410415
getActiveConfigEtagStub = sinon.stub();
411416
getActiveConfigTemplateVersionStub = sinon.stub();
@@ -424,6 +429,10 @@ describe('RemoteConfig', () => {
424429
setActiveConfigTemplateVersionStub;
425430
});
426431

432+
afterEach(() => {
433+
sandbox.restore();
434+
});
435+
427436
it('does not activate if last successful fetch response is undefined', async () => {
428437
getLastSuccessfulFetchResponseStub.returns(Promise.resolve());
429438
getActiveConfigEtagStub.returns(Promise.resolve(ETAG));
@@ -437,6 +446,7 @@ describe('RemoteConfig', () => {
437446
expect(storage.setActiveConfigEtag).to.not.have.been.called;
438447
expect(storageCache.setActiveConfig).to.not.have.been.called;
439448
expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called;
449+
expect(updateActiveExperimentsStub).to.not.have.been.called;
440450
});
441451

442452
it('does not activate if fetched and active etags are the same', async () => {
@@ -455,6 +465,7 @@ describe('RemoteConfig', () => {
455465
expect(storage.setActiveConfigEtag).to.not.have.been.called;
456466
expect(storageCache.setActiveConfig).to.not.have.been.called;
457467
expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called;
468+
expect(updateActiveExperimentsStub).to.not.have.been.called;
458469
});
459470

460471
it('activates if fetched and active etags are different', async () => {
@@ -487,6 +498,7 @@ describe('RemoteConfig', () => {
487498
expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith(
488499
TEMPLATE_VERSION
489500
);
501+
expect(updateActiveExperimentsStub).to.have.been.calledWith(EXPERIMENTS);
490502
});
491503
});
492504

0 commit comments

Comments
 (0)