Skip to content

Commit 5eb6b8d

Browse files
author
Athira M
committed
feat: Add ABT support for remote config
1 parent 2ec305d commit 5eb6b8d

File tree

5 files changed

+183
-0
lines changed

5 files changed

+183
-0
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { ERROR_FACTORY, ErrorCode, hasErrorCode } from './errors';
3434
import { RemoteConfig as RemoteConfigImpl } from './remote_config';
3535
import { Value as ValueImpl } from './value';
3636
import { LogLevel as FirebaseLogLevel } from '@firebase/logger';
37+
import { Experiment } from './abt/experiment';
3738

3839
/**
3940
*
@@ -105,8 +106,10 @@ export async function activate(remoteConfig: RemoteConfig): Promise<boolean> {
105106
// config.
106107
return false;
107108
}
109+
const experiment = new Experiment(rc._storage);
108110
await Promise.all([
109111
rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config),
112+
experiment.updateActiveExperiments(lastSuccessfulFetchResponse.experiments),
110113
rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag)
111114
]);
112115
return true;

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface ThrottleMetadata {
6464
type ProjectNamespaceKeyFieldValue =
6565
| 'active_config'
6666
| 'active_config_etag'
67+
| 'active_experiments'
6768
| 'last_fetch_status'
6869
| 'last_successful_fetch_timestamp_millis'
6970
| 'last_successful_fetch_response'
@@ -156,6 +157,14 @@ export abstract class Storage {
156157
return this.set<string>('active_config_etag', etag);
157158
}
158159

160+
getActiveExperiments(): Promise<Set<string> | undefined> {
161+
return this.get<Set<string>>('active_experiments');
162+
}
163+
164+
setActiveExperiments(experiments:Set<string>): Promise<void> {
165+
return this.set<Set<string>>('active_experiments', experiments);
166+
}
167+
159168
getThrottleMetadata(): Promise<ThrottleMetadata | undefined> {
160169
return this.get<ThrottleMetadata>('throttle_metadata');
161170
}
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
@@ -46,6 +46,7 @@ import {
4646
import * as api from '../src/api';
4747
import { fetchAndActivate } from '../src';
4848
import { restore } from 'sinon';
49+
import { Experiment } from '../src/abt/experiment';
4950

5051
describe('RemoteConfig', () => {
5152
const ACTIVE_CONFIG = {
@@ -388,12 +389,16 @@ describe('RemoteConfig', () => {
388389
"timeToLiveMillis" : "15552000000"
389390
}];
390391

392+
let sandbox: sinon.SinonSandbox;
393+
let updateActiveExperimentsStub: sinon.SinonStub;
391394
let getLastSuccessfulFetchResponseStub: sinon.SinonStub;
392395
let getActiveConfigEtagStub: sinon.SinonStub;
393396
let setActiveConfigEtagStub: sinon.SinonStub;
394397
let setActiveConfigStub: sinon.SinonStub;
395398

396399
beforeEach(() => {
400+
sandbox = sinon.createSandbox();
401+
updateActiveExperimentsStub = sandbox.stub(Experiment.prototype, 'updateActiveExperiments');
397402
getLastSuccessfulFetchResponseStub = sinon.stub();
398403
getActiveConfigEtagStub = sinon.stub();
399404
setActiveConfigEtagStub = sinon.stub();
@@ -406,6 +411,10 @@ describe('RemoteConfig', () => {
406411
storageCache.setActiveConfig = setActiveConfigStub;
407412
});
408413

414+
afterEach(() => {
415+
sandbox.restore();
416+
});
417+
409418
it('does not activate if last successful fetch response is undefined', async () => {
410419
getLastSuccessfulFetchResponseStub.returns(Promise.resolve());
411420
getActiveConfigEtagStub.returns(Promise.resolve(ETAG));
@@ -415,6 +424,7 @@ describe('RemoteConfig', () => {
415424
expect(activateResponse).to.be.false;
416425
expect(storage.setActiveConfigEtag).to.not.have.been.called;
417426
expect(storageCache.setActiveConfig).to.not.have.been.called;
427+
expect(updateActiveExperimentsStub).to.not.have.been.called;
418428
});
419429

420430
it('does not activate if fetched and active etags are the same', async () => {
@@ -428,6 +438,7 @@ describe('RemoteConfig', () => {
428438
expect(activateResponse).to.be.false;
429439
expect(storage.setActiveConfigEtag).to.not.have.been.called;
430440
expect(storageCache.setActiveConfig).to.not.have.been.called;
441+
expect(updateActiveExperimentsStub).to.not.have.been.called;
431442
});
432443

433444
it('activates if fetched and active etags are different', async () => {
@@ -454,6 +465,7 @@ describe('RemoteConfig', () => {
454465
expect(activateResponse).to.be.true;
455466
expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG);
456467
expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG);
468+
expect(updateActiveExperimentsStub).to.have.been.calledWith(EXPERIMENTS);
457469
});
458470
});
459471

0 commit comments

Comments
 (0)