Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2ec305d
feat: Process experiment metadata in RC fetch response
May 30, 2025
5eb6b8d
feat: Add ABT support for remote config
Jun 6, 2025
08c8863
feat: Integrate firebase internal analytics with ABT
Jun 6, 2025
4af3eb9
Merge branch 'web-experiment' into web-exp-fetch
Jul 19, 2025
b6f2ac9
Merge branch 'web-exp-fetch' into web-exp-abt
Jul 19, 2025
55db6e0
Merge branch 'web-exp-abt' into web-exp-ga
Jul 19, 2025
24848c4
[Fix] Storage cache is not updating when there are no experiments in …
Jul 19, 2025
455b8e3
[Fix] Update experiments after checking fetch response
Jul 19, 2025
e2024d7
Merge branch 'web-exp-abt' into web-exp-ga
Jul 19, 2025
bec6e56
feat: Process experiment metadata in RC fetch response
May 30, 2025
ee703b9
[Fix] Storage cache is not updating when there are no experiments in …
Jul 19, 2025
7c67f85
Add result of running yarn docgen:all
Sep 25, 2025
06398f6
feat: Process experiment metadata in RC fetch response
May 30, 2025
fd049e2
feat: Add ABT support for remote config
Jun 6, 2025
638cc2c
[Fix] Storage cache is not updating when there are no experiments in …
Jul 19, 2025
900eff5
Merge conflict fix
Sep 25, 2025
432ac24
Yarn format fix
Sep 25, 2025
ccc71e1
Fix merge conflicts
Sep 25, 2025
5836eaf
merge web-exp-abt
Sep 25, 2025
6be23df
Integrate ABT with Firebase analytics to add experiment as UP
Sep 25, 2025
66b104b
Fix yarn format errors
Sep 25, 2025
b289636
Address review comments
Sep 25, 2025
d3e0838
Fix yarn format failures
Sep 25, 2025
aa7751e
yarn docgen changes added
Sep 25, 2025
19c0fd6
Export firebaseExperimentDescription
Sep 26, 2025
b3f5fa1
Merge branch 'web-exp-fetch' into web-exp-abt
Sep 26, 2025
27cb4b2
Merge branch 'web-exp-abt' into web-exp-ga
Sep 26, 2025
d09a338
Merge branch 'web-experiment' into web-exp-ga
Sep 29, 2025
4c19690
Address review comments
Sep 29, 2025
4eba42d
Address review comments
Oct 1, 2025
bbc8f4d
Add unit tests
Oct 1, 2025
b53e512
Add error handling
Oct 1, 2025
76fd2ad
Remove log
Oct 2, 2025
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
49 changes: 39 additions & 10 deletions packages/remote-config/src/abt/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,22 @@
*/
import { Storage } from '../storage/storage';
import { FirebaseExperimentDescription } from '../public_types';
import { Provider } from '@firebase/component';
import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types';
import { Logger } from '@firebase/logger';
import { RemoteConfig } from '../remote_config';
import { ERROR_FACTORY, ErrorCode } from '../errors';

export class Experiment {
constructor(private readonly storage: Storage) {}
private storage: Storage;
private logger: Logger;
private analyticsProvider: Provider<FirebaseAnalyticsInternalName>;

constructor(rc: RemoteConfig) {
this.storage = rc._storage;
this.logger = rc._logger;
this.analyticsProvider = rc._analyticsProvider;
}

async updateActiveExperiments(
latestExperiments: FirebaseExperimentDescription[]
Expand All @@ -45,32 +58,48 @@ export class Experiment {
currentActiveExperiments: Set<string>,
experimentInfoMap: Map<string, FirebaseExperimentDescription>
): void {
const customProperty: Record<string, string | null> = {};
for (const [experimentId, experimentInfo] of experimentInfoMap.entries()) {
if (!currentActiveExperiments.has(experimentId)) {
this.addExperimentToAnalytics(experimentId, experimentInfo.variantId);
customProperty[experimentId] = experimentInfo.variantId;
}
}
this.addExperimentToAnalytics(customProperty);
}

private removeInactiveExperiments(
currentActiveExperiments: Set<string>,
experimentInfoMap: Map<string, FirebaseExperimentDescription>
): void {
const customProperty: Record<string, string | null> = {};
for (const experimentId of currentActiveExperiments) {
if (!experimentInfoMap.has(experimentId)) {
this.removeExperimentFromAnalytics(experimentId);
customProperty[experimentId] = null;
}
}
this.addExperimentToAnalytics(customProperty);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry if this is a duplicate comment - I can't see the previous one I left anymore - but this should be removeExperimentFromAnalytics, right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was removed as per #9278 (comment)

}

private addExperimentToAnalytics(
_experimentId: string,
_variantId: string
customProperty: Record<string, string | null>
): void {
// TODO
}

private removeExperimentFromAnalytics(_experimentId: string): void {
// TODO
if (Object.keys(customProperty).length === 0) {
return;
}
try {
const analytics = this.analyticsProvider.getImmediate({ optional: true });
if (analytics) {
analytics.setUserProperties({ properties: customProperty });
} else {
// TODO: Update warning message
this.logger.warn(`Analytics is not imported correctly`);
}
} catch (error) {
// TODO: Update error message
this.logger.error(`Failed to add experiment to analytics : ${error}`);
throw ERROR_FACTORY.create(ErrorCode.ANALYTICS_UNAVAILABLE, {
originalErrorMessage: (error as Error)?.message
});
}
}
}
2 changes: 1 addition & 1 deletion packages/remote-config/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export async function activate(remoteConfig: RemoteConfig): Promise<boolean> {
// config.
return false;
}
const experiment = new Experiment(rc._storage);
const experiment = new Experiment(rc);
const updateActiveExperiments = lastSuccessfulFetchResponse.experiments
? experiment.updateActiveExperiments(
lastSuccessfulFetchResponse.experiments
Expand Down
8 changes: 6 additions & 2 deletions packages/remote-config/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export const enum ErrorCode {
CONFIG_UPDATE_STREAM_ERROR = 'stream-error',
CONFIG_UPDATE_UNAVAILABLE = 'realtime-unavailable',
CONFIG_UPDATE_MESSAGE_INVALID = 'update-message-invalid',
CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched'
CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched',
ANALYTICS_UNAVAILABLE = 'analytics-unavailable'
}

const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = {
Expand Down Expand Up @@ -84,7 +85,9 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = {
[ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]:
'The stream invalidation message was unparsable: {$originalErrorMessage}',
[ErrorCode.CONFIG_UPDATE_NOT_FETCHED]:
'Unable to fetch the latest config: {$originalErrorMessage}'
'Unable to fetch the latest config: {$originalErrorMessage}',
[ErrorCode.ANALYTICS_UNAVAILABLE]:
'Connection to firebase analytics failed: {$originalErrorMessage}'
};

// Note this is effectively a type system binding a code to params. This approach overlaps with the
Expand All @@ -108,6 +111,7 @@ interface ErrorParams {
[ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: { originalErrorMessage: string };
[ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: { originalErrorMessage: string };
[ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: { originalErrorMessage: string };
[ErrorCode.ANALYTICS_UNAVAILABLE]: { originalErrorMessage: string };
}

export const ERROR_FACTORY = new ErrorFactory<ErrorCode, ErrorParams>(
Expand Down
4 changes: 3 additions & 1 deletion packages/remote-config/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function registerRemoteConfig(): void {
const installations = container
.getProvider('installations-internal')
.getImmediate();
const analyticsProvider = container.getProvider('analytics-internal');

// Normalizes optional inputs.
const { projectId, apiKey, appId } = app.options;
Expand Down Expand Up @@ -127,7 +128,8 @@ export function registerRemoteConfig(): void {
storageCache,
storage,
logger,
realtimeHandler
realtimeHandler,
analyticsProvider
);

// Starts warming cache.
Expand Down
8 changes: 7 additions & 1 deletion packages/remote-config/src/remote_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { StorageCache } from './storage/storage_cache';
import { RemoteConfigFetchClient } from './client/remote_config_fetch_client';
import { Storage } from './storage/storage';
import { Logger } from '@firebase/logger';
import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types';
import { Provider } from '@firebase/component';
import { RealtimeHandler } from './client/realtime_handler';

const DEFAULT_FETCH_TIMEOUT_MILLIS = 60 * 1000; // One minute
Expand Down Expand Up @@ -88,6 +90,10 @@ export class RemoteConfig implements RemoteConfigType {
/**
* @internal
*/
readonly _realtimeHandler: RealtimeHandler
readonly _realtimeHandler: RealtimeHandler,
/**
* @internal
*/
readonly _analyticsProvider: Provider<FirebaseAnalyticsInternalName>
) {}
}
26 changes: 24 additions & 2 deletions packages/remote-config/test/abt/experiment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,32 @@ import * as sinon from 'sinon';
import { Experiment } from '../../src/abt/experiment';
import { FirebaseExperimentDescription } from '../../src/public_types';
import { Storage } from '../../src/storage/storage';
import { Provider } from '@firebase/component';
import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types';
import { Logger } from '@firebase/logger';
import { RemoteConfig } from '../../src/remote_config';

describe('Experiment', () => {
const storage = {} as Storage;
const experiment = new Experiment(storage);
const analyticsProvider = {} as Provider<FirebaseAnalyticsInternalName>;
const logger = {} as Logger;
const rc = {
_storage: storage,
_analyticsProvider: analyticsProvider,
_logger: logger
} as RemoteConfig;
const experiment = new Experiment(rc);

describe('updateActiveExperiments', () => {
beforeEach(() => {
storage.getActiveExperiments = sinon.stub();
storage.setActiveExperiments = sinon.stub();
analyticsProvider.getImmediate = sinon.stub().returns({
setUserProperties: sinon.stub()
});
});

it('adds mew experiments to storage', async () => {
it('adds new experiments to storage', async () => {
const latestExperiments: FirebaseExperimentDescription[] = [
{
experimentId: '_exp_3',
Expand Down Expand Up @@ -59,12 +73,16 @@ describe('Experiment', () => {
storage.getActiveExperiments = sinon
.stub()
.returns(new Set(['_exp_1', '_exp_2']));
const analytics = analyticsProvider.getImmediate();

await experiment.updateActiveExperiments(latestExperiments);

expect(storage.setActiveExperiments).to.have.been.calledWith(
expectedStoredExperiments
);
expect(analytics.setUserProperties).to.have.been.calledWith({
properties: { '_exp_3': '1' }
});
});

it('removes missing experiment in fetch response from storage', async () => {
Expand All @@ -81,12 +99,16 @@ describe('Experiment', () => {
storage.getActiveExperiments = sinon
.stub()
.returns(new Set(['_exp_1', '_exp_2']));
const analytics = analyticsProvider.getImmediate();

await experiment.updateActiveExperiments(latestExperiments);

expect(storage.setActiveExperiments).to.have.been.calledWith(
expectedStoredExperiments
);
expect(analytics.setUserProperties).to.have.been.calledWith({
properties: { '_exp_2': null }
});
});
});
});
13 changes: 11 additions & 2 deletions packages/remote-config/test/remote_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ import {
import * as api from '../src/api';
import { fetchAndActivate } from '../src';
import { restore } from 'sinon';
import { RealtimeHandler } from '../src/client/realtime_handler';
import { Experiment } from '../src/abt/experiment';
import { Provider } from '@firebase/component';
import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types';
import { RealtimeHandler } from '../src/client/realtime_handler';

describe('RemoteConfig', () => {
const ACTIVE_CONFIG = {
Expand All @@ -71,6 +73,7 @@ describe('RemoteConfig', () => {
let logger: Logger;
let realtimeHandler: RealtimeHandler;
let rc: RemoteConfigType;
let analyticsProvider: Provider<FirebaseAnalyticsInternalName>;

let getActiveConfigStub: sinon.SinonStub;
let loggerDebugSpy: sinon.SinonSpy;
Expand All @@ -82,6 +85,7 @@ describe('RemoteConfig', () => {
client = {} as RemoteConfigFetchClient;
storageCache = {} as StorageCache;
storage = {} as Storage;
analyticsProvider = {} as Provider<FirebaseAnalyticsInternalName>;
realtimeHandler = {} as RealtimeHandler;
logger = new Logger('package-name');
getActiveConfigStub = sinon.stub().returns(undefined);
Expand All @@ -94,7 +98,8 @@ describe('RemoteConfig', () => {
storageCache,
storage,
logger,
realtimeHandler
realtimeHandler,
analyticsProvider
);
});

Expand Down Expand Up @@ -439,6 +444,10 @@ describe('RemoteConfig', () => {
sandbox.restore();
});

afterEach(() => {
sandbox.restore();
});

it('does not activate if last successful fetch response is undefined', async () => {
getLastSuccessfulFetchResponseStub.returns(Promise.resolve());
getActiveConfigEtagStub.returns(Promise.resolve(ETAG));
Expand Down
Loading