Skip to content

Commit 17126e9

Browse files
athiramanuAthira M
andauthored
Add ABT support for remote config
* feat: Process experiment metadata in RC fetch response * [Fix] Storage cache is not updating when there are no experiments in response * Add result of running yarn docgen:all * feat: Process experiment metadata in RC fetch response * feat: Add ABT support for remote config * [Fix] Storage cache is not updating when there are no experiments in response * Merge conflict fix * Yarn format fix * Fix merge conflicts * Address review comments * Fix yarn format failures * yarn docgen changes added * Export firebaseExperimentDescription * Address review comments --------- Co-authored-by: Athira M <athiramanu@google.com>
1 parent 8ee4b72 commit 17126e9

File tree

5 files changed

+201
-1
lines changed

5 files changed

+201
-1
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 =
27+
(await this.storage.getActiveExperiments()) || new Set<string>();
28+
const experimentInfoMap = this.createExperimentInfoMap(latestExperiments);
29+
this.addActiveExperiments(currentActiveExperiments, experimentInfoMap);
30+
this.removeInactiveExperiments(currentActiveExperiments, experimentInfoMap);
31+
return this.storage.setActiveExperiments(new Set(experimentInfoMap.keys()));
32+
}
33+
34+
private createExperimentInfoMap(
35+
latestExperiments: FirebaseExperimentDescription[]
36+
): Map<string, FirebaseExperimentDescription> {
37+
const experimentInfoMap = new Map<string, FirebaseExperimentDescription>();
38+
for (const experiment of latestExperiments) {
39+
experimentInfoMap.set(experiment.experimentId, experiment);
40+
}
41+
return experimentInfoMap;
42+
}
43+
44+
private addActiveExperiments(
45+
currentActiveExperiments: Set<string>,
46+
experimentInfoMap: Map<string, FirebaseExperimentDescription>
47+
): void {
48+
for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) {
49+
if (!currentActiveExperiments.has(experimentId)) {
50+
this.addExperimentToAnalytics(experimentId, experimentInfo.variantId);
51+
}
52+
}
53+
}
54+
55+
private removeInactiveExperiments(
56+
currentActiveExperiments: Set<string>,
57+
experimentInfoMap: Map<string, FirebaseExperimentDescription>
58+
): void {
59+
for (const experimentId of currentActiveExperiments) {
60+
if (!experimentInfoMap.has(experimentId)) {
61+
this.removeExperimentFromAnalytics(experimentId);
62+
}
63+
}
64+
}
65+
66+
private addExperimentToAnalytics(
67+
_experimentId: string,
68+
_variantId: string
69+
): void {
70+
// TODO
71+
}
72+
73+
private removeExperimentFromAnalytics(_experimentId: string): void {
74+
// TODO
75+
}
76+
}

packages/remote-config/src/api.ts

Lines changed: 9 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
*
@@ -110,12 +111,19 @@ export async function activate(remoteConfig: RemoteConfig): Promise<boolean> {
110111
// config.
111112
return false;
112113
}
114+
const experiment = new Experiment(rc._storage);
115+
const updateActiveExperiments = lastSuccessfulFetchResponse.experiments
116+
? experiment.updateActiveExperiments(
117+
lastSuccessfulFetchResponse.experiments
118+
)
119+
: Promise.resolve();
113120
await Promise.all([
114121
rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config),
115122
rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag),
116123
rc._storage.setActiveConfigTemplateVersion(
117124
lastSuccessfulFetchResponse.templateVersion
118-
)
125+
),
126+
updateActiveExperiments
119127
]);
120128
return true;
121129
}

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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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
60+
.stub()
61+
.returns(new Set(['_exp_1', '_exp_2']));
62+
63+
await experiment.updateActiveExperiments(latestExperiments);
64+
65+
expect(storage.setActiveExperiments).to.have.been.calledWith(
66+
expectedStoredExperiments
67+
);
68+
});
69+
70+
it('removes missing experiment in fetch response from storage', async () => {
71+
const latestExperiments: FirebaseExperimentDescription[] = [
72+
{
73+
experimentId: '_exp_1',
74+
variantId: '2',
75+
experimentStartTime: '0',
76+
triggerTimeoutMillis: '0',
77+
timeToLiveMillis: '0'
78+
}
79+
];
80+
const expectedStoredExperiments = new Set(['_exp_1']);
81+
storage.getActiveExperiments = sinon
82+
.stub()
83+
.returns(new Set(['_exp_1', '_exp_2']));
84+
85+
await experiment.updateActiveExperiments(latestExperiments);
86+
87+
expect(storage.setActiveExperiments).to.have.been.calledWith(
88+
expectedStoredExperiments
89+
);
90+
});
91+
});
92+
});

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

Lines changed: 15 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 = {
@@ -401,6 +402,8 @@ describe('RemoteConfig', () => {
401402
}
402403
];
403404

405+
let sandbox: sinon.SinonSandbox;
406+
let updateActiveExperimentsStub: sinon.SinonStub;
404407
let getLastSuccessfulFetchResponseStub: sinon.SinonStub;
405408
let getActiveConfigEtagStub: sinon.SinonStub;
406409
let getActiveConfigTemplateVersionStub: sinon.SinonStub;
@@ -409,6 +412,11 @@ describe('RemoteConfig', () => {
409412
let setActiveConfigTemplateVersionStub: sinon.SinonStub;
410413

411414
beforeEach(() => {
415+
sandbox = sinon.createSandbox();
416+
updateActiveExperimentsStub = sandbox.stub(
417+
Experiment.prototype,
418+
'updateActiveExperiments'
419+
);
412420
getLastSuccessfulFetchResponseStub = sinon.stub();
413421
getActiveConfigEtagStub = sinon.stub();
414422
getActiveConfigTemplateVersionStub = sinon.stub();
@@ -427,6 +435,10 @@ describe('RemoteConfig', () => {
427435
setActiveConfigTemplateVersionStub;
428436
});
429437

438+
afterEach(() => {
439+
sandbox.restore();
440+
});
441+
430442
it('does not activate if last successful fetch response is undefined', async () => {
431443
getLastSuccessfulFetchResponseStub.returns(Promise.resolve());
432444
getActiveConfigEtagStub.returns(Promise.resolve(ETAG));
@@ -440,6 +452,7 @@ describe('RemoteConfig', () => {
440452
expect(storage.setActiveConfigEtag).to.not.have.been.called;
441453
expect(storageCache.setActiveConfig).to.not.have.been.called;
442454
expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called;
455+
expect(updateActiveExperimentsStub).to.not.have.been.called;
443456
});
444457

445458
it('does not activate if fetched and active etags are the same', async () => {
@@ -458,6 +471,7 @@ describe('RemoteConfig', () => {
458471
expect(storage.setActiveConfigEtag).to.not.have.been.called;
459472
expect(storageCache.setActiveConfig).to.not.have.been.called;
460473
expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called;
474+
expect(updateActiveExperimentsStub).to.not.have.been.called;
461475
});
462476

463477
it('activates if fetched and active etags are different', async () => {
@@ -500,6 +514,7 @@ describe('RemoteConfig', () => {
500514
expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith(
501515
TEMPLATE_VERSION
502516
);
517+
expect(updateActiveExperimentsStub).to.have.been.calledWith(EXPERIMENTS);
503518
});
504519
});
505520

0 commit comments

Comments
 (0)